lamhieu commited on
Commit
41788c4
·
1 Parent(s): db8f4bb

refactor(core): overhaul architecture for better performance, efficiency, and maintainability

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +52 -0
  2. .env.example +62 -0
  3. .github/workflows/ci.yml +63 -0
  4. .pre-commit-config.yaml +28 -0
  5. .pylintrc +0 -579
  6. ARCHITECTURE.md +140 -0
  7. CONTRIBUTING.md +42 -0
  8. Dockerfile +94 -32
  9. Makefile +45 -0
  10. README.md +96 -70
  11. app.py +5 -1
  12. docsifer/__init__.py +4 -499
  13. docsifer/analytics.py +0 -290
  14. docsifer/analytics/__init__.py +13 -0
  15. docsifer/analytics/periods.py +23 -0
  16. docsifer/analytics/service.py +193 -0
  17. docsifer/analytics/store.py +183 -0
  18. docsifer/api/__init__.py +1 -0
  19. docsifer/api/deps.py +56 -0
  20. docsifer/api/error_handlers.py +90 -0
  21. docsifer/api/middleware.py +78 -0
  22. docsifer/api/schemas.py +76 -0
  23. docsifer/api/v1/__init__.py +14 -0
  24. docsifer/api/v1/convert.py +213 -0
  25. docsifer/api/v1/health.py +42 -0
  26. docsifer/api/v1/stats.py +27 -0
  27. docsifer/config.py +199 -0
  28. docsifer/core/__init__.py +1 -0
  29. docsifer/core/html_cleaner.py +72 -0
  30. docsifer/core/llm_registry.py +104 -0
  31. docsifer/core/mime.py +79 -0
  32. docsifer/core/service.py +254 -0
  33. docsifer/core/tokenizer.py +52 -0
  34. docsifer/core/url_guard.py +81 -0
  35. docsifer/exceptions.py +107 -0
  36. docsifer/logging_config.py +120 -0
  37. docsifer/main.py +218 -0
  38. docsifer/router.py +0 -129
  39. docsifer/safety/__init__.py +17 -0
  40. docsifer/safety/circuit_breaker.py +66 -0
  41. docsifer/safety/conversion_gate.py +62 -0
  42. docsifer/safety/disk_cleanup.py +48 -0
  43. docsifer/safety/memory_watchdog.py +56 -0
  44. docsifer/safety/per_ip_limiter.py +65 -0
  45. docsifer/safety/resource_guard.py +67 -0
  46. docsifer/service.py +0 -227
  47. docsifer/ui/__init__.py +5 -0
  48. docsifer/ui/gradio_app.py +385 -0
  49. pyproject.toml +85 -5
  50. requirements.txt +25 -14
.dockerignore ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # VCS / IDE
2
+ .git
3
+ .gitignore
4
+ .github
5
+ .gitattributes
6
+ .idea
7
+ .vscode
8
+ .editorconfig
9
+ .windsurf
10
+ .cursor
11
+
12
+ # Python
13
+ __pycache__
14
+ *.py[cod]
15
+ *.so
16
+ *.egg
17
+ *.egg-info
18
+ .Python
19
+ .venv
20
+ venv
21
+ env
22
+ .tox
23
+ .nox
24
+ .pytest_cache
25
+ .mypy_cache
26
+ .ruff_cache
27
+ .coverage
28
+ .coverage.*
29
+ htmlcov
30
+ dist
31
+ build
32
+ pip-log.txt
33
+ pip-delete-this-directory.txt
34
+
35
+ # Project
36
+ tests
37
+ docs
38
+ .env
39
+ .env.*
40
+ !.env.example
41
+ *.log
42
+ *.local
43
+ *.bak
44
+ .DS_Store
45
+
46
+ # Tooling / docs that don't belong in the runtime image
47
+ Makefile
48
+ .pre-commit-config.yaml
49
+ ARCHITECTURE.md
50
+ CONTRIBUTING.md
51
+ LICENSE
52
+ poetry.lock
.env.example ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # Docsifer environment configuration
3
+ # Copy to ".env" and adjust for your environment.
4
+ # =============================================================================
5
+
6
+ # General
7
+ DOCSIFER_ENVIRONMENT=production
8
+ DOCSIFER_LOG_LEVEL=INFO
9
+ DOCSIFER_LOG_JSON=true
10
+
11
+ # HTTP / API
12
+ DOCSIFER_CORS_ORIGINS=*
13
+ DOCSIFER_CORS_ALLOW_CREDENTIALS=false
14
+ DOCSIFER_REQUEST_TIMEOUT_SEC=55
15
+ DOCSIFER_MAX_UPLOAD_BYTES=10485760
16
+ DOCSIFER_GZIP_MIN_SIZE=1024
17
+ DOCSIFER_ENABLE_SECURITY_HEADERS=true
18
+
19
+ # Concurrency / resources (tune to your hardware)
20
+ DOCSIFER_MAX_CONCURRENT_CONVERSIONS=2
21
+ DOCSIFER_MAX_QUEUE_DEPTH=10
22
+ DOCSIFER_MAX_PER_IP_CONCURRENT=1
23
+ DOCSIFER_WORKER_POOL_SIZE=4
24
+ DOCSIFER_MIN_FREE_MEMORY_MB=512
25
+ DOCSIFER_MIN_FREE_DISK_MB=256
26
+ DOCSIFER_MEMORY_WATCHDOG_PCT=90
27
+ DOCSIFER_ENABLE_MEMORY_WATCHDOG=false
28
+ DOCSIFER_DISK_CLEANUP_INTERVAL_SEC=600
29
+ DOCSIFER_DISK_CLEANUP_TTL_SEC=3600
30
+
31
+ # Conversion
32
+ DOCSIFER_TOKEN_MODEL=gpt-4o
33
+ DOCSIFER_DEFAULT_OPENAI_BASE_URL=https://api.openai.com/v1
34
+ DOCSIFER_DEFAULT_OPENAI_MODEL=gpt-4o-mini
35
+ DOCSIFER_OPENAI_REQUEST_TIMEOUT_SEC=60
36
+ DOCSIFER_OPENAI_CONNECT_TIMEOUT_SEC=10
37
+ DOCSIFER_OPENAI_MAX_RETRIES=2
38
+ DOCSIFER_LLM_CACHE_MAX_SIZE=16
39
+ DOCSIFER_LLM_CACHE_TTL_SEC=600
40
+
41
+ # SSRF guard
42
+ DOCSIFER_URL_ALLOW_PRIVATE_NETWORKS=false
43
+ DOCSIFER_URL_ALLOWED_SCHEMES=http,https
44
+
45
+ # Analytics / Redis (Upstash recommended; leave empty for in-memory mode)
46
+ DOCSIFER_REDIS_URL=
47
+ DOCSIFER_REDIS_TOKEN=
48
+ DOCSIFER_ANALYTICS_ENABLED=true
49
+ DOCSIFER_ANALYTICS_SYNC_INTERVAL_SEC=1800
50
+ DOCSIFER_ANALYTICS_LABEL=docsifer
51
+
52
+ # Quotas
53
+ DOCSIFER_QUOTA_ENABLED=false
54
+ DOCSIFER_QUOTA_ANON_RPH=10
55
+ DOCSIFER_QUOTA_ANON_RPD=50
56
+ DOCSIFER_QUOTA_BYOK_RPH=60
57
+ DOCSIFER_QUOTA_BYOK_RPD=500
58
+ DOCSIFER_QUOTA_AUTH_KEYS=
59
+
60
+ # Observability
61
+ DOCSIFER_ENABLE_PROMETHEUS=false
62
+ DOCSIFER_SENTRY_DSN=
.github/workflows/ci.yml ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ concurrency:
10
+ group: ci-${{ github.ref }}
11
+ cancel-in-progress: true
12
+
13
+ jobs:
14
+ lint-test:
15
+ name: Lint, type-check, test
16
+ runs-on: ubuntu-latest
17
+ strategy:
18
+ fail-fast: false
19
+ matrix:
20
+ python-version: ["3.10", "3.11", "3.12"]
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+
24
+ - name: Setup Python
25
+ uses: actions/setup-python@v5
26
+ with:
27
+ python-version: ${{ matrix.python-version }}
28
+ cache: pip
29
+
30
+ - name: Install system deps
31
+ run: |
32
+ sudo apt-get update
33
+ sudo apt-get install -y libmagic1 ffmpeg
34
+
35
+ - name: Install Python deps
36
+ run: |
37
+ python -m pip install --upgrade pip wheel
38
+ pip install -r requirements.txt
39
+ pip install pytest pytest-asyncio ruff mypy fakeredis
40
+
41
+ - name: Ruff lint
42
+ run: ruff check .
43
+
44
+ - name: Ruff format check
45
+ run: ruff format --check .
46
+
47
+ - name: Mypy
48
+ run: mypy
49
+ continue-on-error: true # tighten over time
50
+
51
+ - name: Pytest
52
+ env:
53
+ DOCSIFER_LOG_JSON: "false"
54
+ run: pytest -q
55
+
56
+ docker-build:
57
+ name: Docker build
58
+ runs-on: ubuntu-latest
59
+ needs: lint-test
60
+ steps:
61
+ - uses: actions/checkout@v4
62
+ - name: Build image
63
+ run: docker build -t docsifer:ci .
.pre-commit-config.yaml ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v4.6.0
4
+ hooks:
5
+ - id: trailing-whitespace
6
+ - id: end-of-file-fixer
7
+ - id: check-yaml
8
+ - id: check-added-large-files
9
+ args: [--maxkb=1024]
10
+ - id: check-merge-conflict
11
+ - id: detect-private-key
12
+
13
+ - repo: https://github.com/astral-sh/ruff-pre-commit
14
+ rev: v0.6.9
15
+ hooks:
16
+ - id: ruff
17
+ args: [--fix]
18
+ - id: ruff-format
19
+
20
+ - repo: https://github.com/pre-commit/mirrors-mypy
21
+ rev: v1.11.2
22
+ hooks:
23
+ - id: mypy
24
+ additional_dependencies:
25
+ - pydantic>=2.6
26
+ - pydantic-settings>=2.2
27
+ args: [--config-file=pyproject.toml]
28
+ exclude: ^tests/
.pylintrc DELETED
@@ -1,579 +0,0 @@
1
- [MAIN]
2
-
3
- # Specify a configuration file.
4
- #rcfile=
5
-
6
- # Python code to execute, usually for sys.path manipulation such as
7
- # pygtk.require().
8
- #init-hook=
9
-
10
- # Files or directories to be skipped. They should be base names, not
11
- # paths.
12
- ignore=CVS
13
-
14
- # Add files or directories matching the regex patterns to the ignore-list. The
15
- # regex matches against paths and can be in Posix or Windows format.
16
- ignore-paths=
17
-
18
- # Files or directories matching the regex patterns are skipped. The regex
19
- # matches against base names, not paths.
20
- ignore-patterns=^\.#
21
-
22
- # Pickle collected data for later comparisons.
23
- persistent=yes
24
-
25
- # List of plugins (as comma separated values of python modules names) to load,
26
- # usually to register additional checkers.
27
- load-plugins=
28
- pylint.extensions.check_elif,
29
- pylint.extensions.bad_builtin,
30
- pylint.extensions.docparams,
31
- pylint.extensions.for_any_all,
32
- pylint.extensions.set_membership,
33
- pylint.extensions.code_style,
34
- pylint.extensions.overlapping_exceptions,
35
- pylint.extensions.typing,
36
- pylint.extensions.redefined_variable_type,
37
- pylint.extensions.comparison_placement,
38
- pylint.extensions.mccabe,
39
-
40
- # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
41
- # number of processors available to use.
42
- jobs=0
43
-
44
- # When enabled, pylint would attempt to guess common misconfiguration and emit
45
- # user-friendly hints instead of false-positive error messages.
46
- suggestion-mode=yes
47
-
48
- # Allow loading of arbitrary C extensions. Extensions are imported into the
49
- # active Python interpreter and may run arbitrary code.
50
- unsafe-load-any-extension=no
51
-
52
- # A comma-separated list of package or module names from where C extensions may
53
- # be loaded. Extensions are loading into the active Python interpreter and may
54
- # run arbitrary code
55
- extension-pkg-allow-list=
56
-
57
- # Minimum supported python version
58
- py-version = 3.7.2
59
-
60
- # Control the amount of potential inferred values when inferring a single
61
- # object. This can help the performance when dealing with large functions or
62
- # complex, nested conditions.
63
- limit-inference-results=100
64
-
65
- # Specify a score threshold to be exceeded before program exits with error.
66
- fail-under=10.0
67
-
68
- # Return non-zero exit code if any of these messages/categories are detected,
69
- # even if score is above --fail-under value. Syntax same as enable. Messages
70
- # specified are enabled, while categories only check already-enabled messages.
71
- fail-on=
72
-
73
-
74
- [MESSAGES CONTROL]
75
-
76
- # Only show warnings with the listed confidence levels. Leave empty to show
77
- # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED
78
- # confidence=
79
-
80
- # Enable the message, report, category or checker with the given id(s). You can
81
- # either give multiple identifier separated by comma (,) or put this option
82
- # multiple time (only on the command line, not in the configuration file where
83
- # it should appear only once). See also the "--disable" option for examples.
84
- enable=
85
- use-symbolic-message-instead,
86
- useless-suppression,
87
-
88
- # Disable the message, report, category or checker with the given id(s). You
89
- # can either give multiple identifiers separated by comma (,) or put this
90
- # option multiple times (only on the command line, not in the configuration
91
- # file where it should appear only once).You can also use "--disable=all" to
92
- # disable everything first and then re-enable specific checks. For example, if
93
- # you want to run only the similarities checker, you can use "--disable=all
94
- # --enable=similarities". If you want to run only the classes checker, but have
95
- # no Warning level messages displayed, use"--disable=all --enable=classes
96
- # --disable=W"
97
-
98
- disable=
99
- attribute-defined-outside-init,
100
- invalid-name,
101
- missing-docstring,
102
- protected-access,
103
- too-few-public-methods,
104
- # handled by black
105
- format,
106
- # We anticipate #3512 where it will become optional
107
- fixme,
108
- cyclic-import,
109
- import-error,
110
- #
111
- unnecessary-pass,
112
- unrecognized-option,
113
- cell-var-from-loop,
114
- no-member,
115
- wrong-import-order,
116
- raise-missing-from,
117
- consider-using-f-string
118
-
119
-
120
- [REPORTS]
121
-
122
- # Set the output format. Available formats are text, parseable, colorized, msvs
123
- # (visual studio) and html. You can also give a reporter class, eg
124
- # mypackage.mymodule.MyReporterClass.
125
- output-format=text
126
-
127
- # Tells whether to display a full report or only the messages
128
- reports=no
129
-
130
- # Python expression which should return a note less than 10 (10 is the highest
131
- # note). You have access to the variables 'fatal', 'error', 'warning', 'refactor', 'convention'
132
- # and 'info', which contain the number of messages in each category, as
133
- # well as 'statement', which is the total number of statements analyzed. This
134
- # score is used by the global evaluation report (RP0004).
135
- evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
136
-
137
- # Template used to display messages. This is a python new-style format string
138
- # used to format the message information. See doc for all details
139
- #msg-template=
140
-
141
- # Activate the evaluation score.
142
- score=yes
143
-
144
-
145
- [LOGGING]
146
-
147
- # Logging modules to check that the string format arguments are in logging
148
- # function parameter format
149
- logging-modules=logging
150
-
151
- # The type of string formatting that logging methods do. `old` means using %
152
- # formatting, `new` is for `{}` formatting.
153
- logging-format-style=old
154
-
155
-
156
- [MISCELLANEOUS]
157
-
158
- # List of note tags to take in consideration, separated by a comma.
159
- notes=FIXME,XXX,TODO
160
-
161
- # Regular expression of note tags to take in consideration.
162
- #notes-rgx=
163
-
164
-
165
- [SIMILARITIES]
166
-
167
- # Minimum lines number of a similarity.
168
- min-similarity-lines=6
169
-
170
- # Ignore comments when computing similarities.
171
- ignore-comments=yes
172
-
173
- # Ignore docstrings when computing similarities.
174
- ignore-docstrings=yes
175
-
176
- # Ignore imports when computing similarities.
177
- ignore-imports=yes
178
-
179
- # Signatures are removed from the similarity computation
180
- ignore-signatures=yes
181
-
182
-
183
- [VARIABLES]
184
-
185
- # Tells whether we should check for unused import in __init__ files.
186
- init-import=no
187
-
188
- # A regular expression matching the name of dummy variables (i.e. expectedly
189
- # not used).
190
- dummy-variables-rgx=_$|dummy
191
-
192
- # List of additional names supposed to be defined in builtins. Remember that
193
- # you should avoid defining new builtins when possible.
194
- additional-builtins=
195
-
196
- # List of strings which can identify a callback function by name. A callback
197
- # name must start or end with one of those strings.
198
- callbacks=cb_,_cb
199
-
200
- # Tells whether unused global variables should be treated as a violation.
201
- allow-global-unused-variables=yes
202
-
203
- # List of names allowed to shadow builtins
204
- allowed-redefined-builtins=
205
-
206
- # Argument names that match this expression will be ignored. Default to name
207
- # with leading underscore.
208
- ignored-argument-names=_.*
209
-
210
- # List of qualified module names which can have objects that can redefine
211
- # builtins.
212
- redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
213
-
214
-
215
- [FORMAT]
216
-
217
- # Maximum number of characters on a single line.
218
- max-line-length=120
219
-
220
- # Regexp for a line that is allowed to be longer than the limit.
221
- ignore-long-lines=^\s*(# )?<?https?://\S+>?$
222
-
223
- # Allow the body of an if to be on the same line as the test if there is no
224
- # else.
225
- single-line-if-stmt=no
226
-
227
- # Allow the body of a class to be on the same line as the declaration if body
228
- # contains single statement.
229
- single-line-class-stmt=no
230
-
231
- # Maximum number of lines in a module
232
- max-module-lines=1000
233
-
234
- # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
235
- # tab).
236
- indent-string=' '
237
-
238
- # Number of spaces of indent required inside a hanging or continued line.
239
- indent-after-paren=4
240
-
241
- # Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
242
- expected-line-ending-format=
243
-
244
-
245
- [BASIC]
246
-
247
- # Good variable names which should always be accepted, separated by a comma
248
- good-names=i,j,k,ex,Run,_
249
-
250
- # Good variable names regexes, separated by a comma. If names match any regex,
251
- # they will always be accepted
252
- good-names-rgxs=
253
-
254
- # Bad variable names which should always be refused, separated by a comma
255
- bad-names=foo,bar,baz,toto,tutu,tata
256
-
257
- # Bad variable names regexes, separated by a comma. If names match any regex,
258
- # they will always be refused
259
- bad-names-rgxs=
260
-
261
- # Colon-delimited sets of names that determine each other's naming style when
262
- # the name regexes allow several styles.
263
- name-group=
264
-
265
- # Include a hint for the correct naming format with invalid-name
266
- include-naming-hint=no
267
-
268
- # Naming style matching correct function names.
269
- function-naming-style=snake_case
270
-
271
- # Regular expression matching correct function names
272
- function-rgx=[a-z_][a-z0-9_]{2,30}$
273
-
274
- # Naming style matching correct variable names.
275
- variable-naming-style=snake_case
276
-
277
- # Regular expression matching correct variable names
278
- variable-rgx=[a-z_][a-z0-9_]{2,30}$
279
-
280
- # Naming style matching correct constant names.
281
- const-naming-style=UPPER_CASE
282
-
283
- # Regular expression matching correct constant names
284
- const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$
285
-
286
- # Naming style matching correct attribute names.
287
- attr-naming-style=snake_case
288
-
289
- # Regular expression matching correct attribute names
290
- attr-rgx=[a-z_][a-z0-9_]{2,}$
291
-
292
- # Naming style matching correct argument names.
293
- argument-naming-style=snake_case
294
-
295
- # Regular expression matching correct argument names
296
- argument-rgx=[a-z_][a-z0-9_]{2,30}$
297
-
298
- # Naming style matching correct class attribute names.
299
- class-attribute-naming-style=any
300
-
301
- # Regular expression matching correct class attribute names
302
- class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$
303
-
304
- # Naming style matching correct class constant names.
305
- class-const-naming-style=UPPER_CASE
306
-
307
- # Regular expression matching correct class constant names. Overrides class-
308
- # const-naming-style.
309
- #class-const-rgx=
310
-
311
- # Naming style matching correct inline iteration names.
312
- inlinevar-naming-style=any
313
-
314
- # Regular expression matching correct inline iteration names
315
- inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$
316
-
317
- # Naming style matching correct class names.
318
- class-naming-style=PascalCase
319
-
320
- # Regular expression matching correct class names
321
- class-rgx=[A-Z_][a-zA-Z0-9]+$
322
-
323
-
324
- # Naming style matching correct module names.
325
- module-naming-style=snake_case
326
-
327
- # Regular expression matching correct module names
328
- module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
329
-
330
-
331
- # Naming style matching correct method names.
332
- method-naming-style=snake_case
333
-
334
- # Regular expression matching correct method names
335
- method-rgx=[a-z_][a-z0-9_]{2,}$
336
-
337
- # Regular expression which can overwrite the naming style set by typevar-naming-style.
338
- #typevar-rgx=
339
-
340
- # Regular expression which should only match function or class names that do
341
- # not require a docstring. Use ^(?!__init__$)_ to also check __init__.
342
- no-docstring-rgx=__.*__
343
-
344
- # Minimum line length for functions/classes that require docstrings, shorter
345
- # ones are exempt.
346
- docstring-min-length=-1
347
-
348
- # List of decorators that define properties, such as abc.abstractproperty.
349
- property-classes=abc.abstractproperty
350
-
351
-
352
- [TYPECHECK]
353
-
354
- # Regex pattern to define which classes are considered mixins if ignore-mixin-
355
- # members is set to 'yes'
356
- mixin-class-rgx=.*MixIn
357
-
358
- # List of module names for which member attributes should not be checked
359
- # (useful for modules/projects where namespaces are manipulated during runtime
360
- # and thus existing member attributes cannot be deduced by static analysis). It
361
- # supports qualified module names, as well as Unix pattern matching.
362
- ignored-modules=
363
-
364
- # List of class names for which member attributes should not be checked (useful
365
- # for classes with dynamically set attributes). This supports the use of
366
- # qualified names.
367
- ignored-classes=SQLObject, optparse.Values, thread._local, _thread._local
368
-
369
- # List of members which are set dynamically and missed by pylint inference
370
- # system, and so shouldn't trigger E1101 when accessed. Python regular
371
- # expressions are accepted.
372
- generated-members=REQUEST,acl_users,aq_parent,argparse.Namespace
373
-
374
- # List of decorators that create context managers from functions, such as
375
- # contextlib.contextmanager.
376
- contextmanager-decorators=contextlib.contextmanager
377
-
378
- # Tells whether to warn about missing members when the owner of the attribute
379
- # is inferred to be None.
380
- ignore-none=yes
381
-
382
- # This flag controls whether pylint should warn about no-member and similar
383
- # checks whenever an opaque object is returned when inferring. The inference
384
- # can return multiple potential results while evaluating a Python object, but
385
- # some branches might not be evaluated, which results in partial inference. In
386
- # that case, it might be useful to still emit no-member and other checks for
387
- # the rest of the inferred objects.
388
- ignore-on-opaque-inference=yes
389
-
390
- # Show a hint with possible names when a member name was not found. The aspect
391
- # of finding the hint is based on edit distance.
392
- missing-member-hint=yes
393
-
394
- # The minimum edit distance a name should have in order to be considered a
395
- # similar match for a missing member name.
396
- missing-member-hint-distance=1
397
-
398
- # The total number of similar names that should be taken in consideration when
399
- # showing a hint for a missing member.
400
- missing-member-max-choices=1
401
-
402
- [SPELLING]
403
-
404
- # Spelling dictionary name. Available dictionaries: none. To make it working
405
- # install python-enchant package.
406
- spelling-dict=
407
-
408
- # List of comma separated words that should not be checked.
409
- spelling-ignore-words=
410
-
411
- # List of comma separated words that should be considered directives if they
412
- # appear and the beginning of a comment and should not be checked.
413
- spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:,pragma:,# noinspection
414
-
415
- # A path to a file that contains private dictionary; one word per line.
416
- spelling-private-dict-file=.pyenchant_pylint_custom_dict.txt
417
-
418
- # Tells whether to store unknown words to indicated private dictionary in
419
- # --spelling-private-dict-file option instead of raising a message.
420
- spelling-store-unknown-words=no
421
-
422
- # Limits count of emitted suggestions for spelling mistakes.
423
- max-spelling-suggestions=2
424
-
425
-
426
- [DESIGN]
427
-
428
- # Maximum number of arguments for function / method
429
- max-args=10
430
-
431
- # Maximum number of locals for function / method body
432
- max-locals=25
433
-
434
- # Maximum number of return / yield for function / method body
435
- max-returns=11
436
-
437
- # Maximum number of branch for function / method body
438
- max-branches=27
439
-
440
- # Maximum number of statements in function / method body
441
- max-statements=100
442
-
443
- # Maximum number of parents for a class (see R0901).
444
- max-parents=7
445
-
446
- # List of qualified class names to ignore when counting class parents (see R0901).
447
- ignored-parents=
448
-
449
- # Maximum number of attributes for a class (see R0902).
450
- max-attributes=11
451
-
452
- # Minimum number of public methods for a class (see R0903).
453
- min-public-methods=2
454
-
455
- # Maximum number of public methods for a class (see R0904).
456
- max-public-methods=25
457
-
458
- # Maximum number of boolean expressions in an if statement (see R0916).
459
- max-bool-expr=5
460
-
461
- # List of regular expressions of class ancestor names to
462
- # ignore when counting public methods (see R0903).
463
- exclude-too-few-public-methods=
464
-
465
- max-complexity=10
466
-
467
- [CLASSES]
468
-
469
- # List of method names used to declare (i.e. assign) instance attributes.
470
- defining-attr-methods=__init__,__new__,setUp,__post_init__
471
-
472
- # List of valid names for the first argument in a class method.
473
- valid-classmethod-first-arg=cls
474
-
475
- # List of valid names for the first argument in a metaclass class method.
476
- valid-metaclass-classmethod-first-arg=mcs
477
-
478
- # List of member names, which should be excluded from the protected access
479
- # warning.
480
- exclude-protected=_asdict,_fields,_replace,_source,_make
481
-
482
- # Warn about protected attribute access inside special methods
483
- check-protected-access-in-special-methods=no
484
-
485
- [IMPORTS]
486
-
487
- # List of modules that can be imported at any level, not just the top level
488
- # one.
489
- allow-any-import-level=
490
-
491
- # Allow wildcard imports from modules that define __all__.
492
- allow-wildcard-with-all=no
493
-
494
- # Analyse import fallback blocks. This can be used to support both Python 2 and
495
- # 3 compatible code, which means that the block might have code that exists
496
- # only in one or another interpreter, leading to false positives when analysed.
497
- analyse-fallback-blocks=no
498
-
499
- # Deprecated modules which should not be used, separated by a comma
500
- deprecated-modules=regsub,TERMIOS,Bastion,rexec
501
-
502
- # Create a graph of every (i.e. internal and external) dependencies in the
503
- # given file (report RP0402 must not be disabled)
504
- import-graph=
505
-
506
- # Create a graph of external dependencies in the given file (report RP0402 must
507
- # not be disabled)
508
- ext-import-graph=
509
-
510
- # Create a graph of internal dependencies in the given file (report RP0402 must
511
- # not be disabled)
512
- int-import-graph=
513
-
514
- # Force import order to recognize a module as part of the standard
515
- # compatibility libraries.
516
- known-standard-library=
517
-
518
- # Force import order to recognize a module as part of a third party library.
519
- known-third-party=enchant
520
-
521
- # Couples of modules and preferred modules, separated by a comma.
522
- preferred-modules=
523
-
524
-
525
- [EXCEPTIONS]
526
-
527
- # Exceptions that will emit a warning when being caught. Defaults to
528
- # "Exception"
529
- overgeneral-exceptions=Exception
530
-
531
-
532
- [TYPING]
533
-
534
- # Set to ``no`` if the app / library does **NOT** need to support runtime
535
- # introspection of type annotations. If you use type annotations
536
- # **exclusively** for type checking of an application, you're probably fine.
537
- # For libraries, evaluate if some users what to access the type hints at
538
- # runtime first, e.g., through ``typing.get_type_hints``. Applies to Python
539
- # versions 3.7 - 3.9
540
- runtime-typing = no
541
-
542
-
543
- [DEPRECATED_BUILTINS]
544
-
545
- # List of builtins function names that should not be used, separated by a comma
546
- bad-functions=map,input
547
-
548
-
549
- [REFACTORING]
550
-
551
- # Maximum number of nested blocks for function / method body
552
- max-nested-blocks=5
553
-
554
- # Complete name of functions that never returns. When checking for
555
- # inconsistent-return-statements if a never returning function is called then
556
- # it will be considered as an explicit return statement and no message will be
557
- # printed.
558
- never-returning-functions=sys.exit,argparse.parse_error
559
-
560
-
561
- [STRING]
562
-
563
- # This flag controls whether inconsistent-quotes generates a warning when the
564
- # character used as a quote delimiter is used inconsistently within a module.
565
- check-quote-consistency=no
566
-
567
- # This flag controls whether the implicit-str-concat should generate a warning
568
- # on implicit string concatenation in sequences defined over several lines.
569
- check-str-concat-over-line-jumps=no
570
-
571
-
572
- [CODE_STYLE]
573
-
574
- # Max line length for which to sill emit suggestions. Used to prevent optional
575
- # suggestions which would get split by a code formatter (e.g., black). Will
576
- # default to the setting for ``max-line-length``.
577
- #max-line-length-suggestions=
578
-
579
- W0107:unnecessary-pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ARCHITECTURE.md ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Architecture
2
+
3
+ This document explains how the Docsifer codebase is structured and why.
4
+
5
+ ## Goals
6
+
7
+ 1. **Correctness** — eliminate the bugs identified in the audit (SSRF,
8
+ path-traversal, broken cookies, race conditions, deprecated FastAPI APIs).
9
+ 2. **Production-grade safety** — never crash a free-tier container under load.
10
+ 3. **Operability** — config driven, observable, well-tested.
11
+ 4. **Performance** — minimize redundant I/O, reuse expensive clients.
12
+
13
+ ## Module map
14
+
15
+ ```
16
+ docsifer/
17
+ ├── api/ # HTTP layer
18
+ │ ├── deps.py
19
+ │ ├── error_handlers.py
20
+ │ ├── middleware.py # request-id, body limit, security headers
21
+ │ ├── schemas.py # Pydantic request/response models
22
+ │ └── v1/
23
+ │ ├── convert.py # POST /v1/convert
24
+ │ ├── stats.py # GET /v1/stats
25
+ │ └── health.py # GET /v1/healthz, /v1/readyz
26
+ ├── core/ # Pure business logic
27
+ │ ├── service.py # DocsiferService
28
+ │ ├── llm_registry.py # TTL-cached MarkItDown(LLM) instances
29
+ │ ├── html_cleaner.py # selectolax-based HTML scrub
30
+ │ ├── tokenizer.py # tiktoken wrapper with fallbacks
31
+ │ ├── mime.py # safe MIME detection
32
+ │ └── url_guard.py # SSRF protection
33
+ ├── analytics/ # Lifespan-managed analytics
34
+ │ ├── service.py
35
+ │ ├── periods.py # ISO 8601 week numbering
36
+ │ └── store.py # AnalyticsStore protocol + Upstash & in-memory
37
+ ├── safety/ # Anti-crash primitives (Section N of the audit)
38
+ │ ├── conversion_gate.py
39
+ │ ├── per_ip_limiter.py
40
+ │ ├── resource_guard.py
41
+ │ ├── circuit_breaker.py
42
+ │ ├── memory_watchdog.py
43
+ │ └── disk_cleanup.py
44
+ ├── ui/ # Gradio UI (optional)
45
+ │ └── gradio_app.py
46
+ ├── config.py # pydantic-settings
47
+ ├── exceptions.py
48
+ ├── logging_config.py
49
+ └── main.py # FastAPI app factory + lifespan
50
+ ```
51
+
52
+ ## Request flow (POST /v1/convert)
53
+
54
+ ```
55
+ Client
56
+ │ multipart/form-data
57
+
58
+ [ middleware ]
59
+ ├─ request_id → X-Request-ID header + ContextVar
60
+ ├─ body_limit → 413 if Content-Length > MAX
61
+ └─ security_headers → X-Content-Type-Options, X-Frame-Options, …
62
+
63
+
64
+ [ route handler convert.py ]
65
+ ├─ parse JSON forms via Pydantic (422 on errors)
66
+ ├─ acquire PerIPLimiter slot (429 on overflow)
67
+ ├─ acquire ConversionGate slot (503 when full)
68
+ ├─ stream upload to disk in worker thread
69
+ ├─ ResourceGuard checks RAM/disk
70
+ ├─ asyncio.wait_for() guards against runaway conversions
71
+ └─ DocsiferService.convert_file(...)
72
+
73
+
74
+ [ DocsiferService (core) ]
75
+ ├─ choose converter:
76
+ │ ├─ no api_key → cached `MarkItDown()`
77
+ │ └─ otherwise → LLMRegistry.get(LLMConfig)
78
+ ├─ HTML path → clean in-memory → MarkItDown.convert_stream()
79
+ └─ everything else → normalize_extension() → MarkItDown.convert(path)
80
+ ```
81
+
82
+ The route returns an `ORJSONResponse`; large markdown payloads are gzipped by
83
+ the `GZipMiddleware`.
84
+
85
+ ## Lifespan
86
+
87
+ `docsifer.main._lifespan` owns the lifecycle of every singleton:
88
+
89
+ - `DocsiferService` — built once, has a bounded `ThreadPoolExecutor`.
90
+ - `AnalyticsService` — loads totals from Upstash, kicks off the
91
+ background sync loop, **flushes pending counters on shutdown**.
92
+ - `ConversionGate`, `PerIPLimiter`, `ResourceGuard` — no I/O, just config.
93
+ - `disk_cleanup_loop` — periodic temp-file sweeper.
94
+ - `memory_watchdog_loop` — optional, sends SIGTERM on RSS overrun.
95
+
96
+ All background tasks share an `asyncio.Event` so shutdown is deterministic.
97
+
98
+ ## Concurrency model
99
+
100
+ - One global `asyncio.Semaphore` (in `ConversionGate`) bounds in-flight
101
+ conversions. Anything beyond `max_concurrent + max_queue` is rejected with
102
+ `503 + Retry-After`.
103
+ - `PerIPLimiter` enforces fairness so a single IP cannot monopolize the gate.
104
+ - `ResourceGuard` runs ahead of every conversion to short-circuit OOM.
105
+ - All synchronous work happens in a dedicated `ThreadPoolExecutor` to keep
106
+ the event loop responsive.
107
+
108
+ ## Analytics
109
+
110
+ - Increments are stored in an in-process `pending` counter and applied to a
111
+ `totals` counter atomically. The lock-free `_snapshot` dict is what
112
+ `/v1/stats` returns — readers are never blocked by writers.
113
+ - The background sync loop pipelines `HINCRBY` operations into Upstash so a
114
+ full flush is a single HTTP round-trip when the server supports pipelines.
115
+ - Failures keep `pending` intact for the next attempt; on shutdown the
116
+ service performs one final flush.
117
+
118
+ ## Security posture
119
+
120
+ - **SSRF** — every URL goes through `validate_url()` which resolves the host
121
+ and rejects private/loopback/link-local/multicast addresses by default.
122
+ - **Path traversal** — `Path(filename).name` strips any directory component.
123
+ - **Body limit** — enforced by middleware before the body is consumed.
124
+ - **Extension allowlist** — server-side allowlist independent of the UI.
125
+ - **CORS** — `allow_credentials` is automatically disabled when origins
126
+ contain `*` (which would otherwise be invalid per the spec).
127
+ - **Error responses** — never echo internal exception messages; only the
128
+ `public_message` of the typed exception is returned.
129
+
130
+ ## Free-tier defaults
131
+
132
+ The defaults in `config.py` target a 2 vCPU / 16 GB RAM container (Hugging
133
+ Face Spaces basic):
134
+
135
+ - `max_upload_bytes = 10 MB`
136
+ - `max_concurrent_conversions = 2`
137
+ - `max_queue_depth = 10`
138
+ - `max_per_ip_concurrent = 1`
139
+ - `request_timeout_sec = 55` (just under HF's 60 s gateway)
140
+ - `analytics_sync_interval_sec = 1800`
CONTRIBUTING.md ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributing
2
+
3
+ Thanks for your interest in Docsifer.
4
+
5
+ ## Development setup
6
+
7
+ ```bash
8
+ git clone https://github.com/lh0x00/docsifer.git
9
+ cd docsifer
10
+ make install
11
+ cp .env.example .env
12
+ make run
13
+ ```
14
+
15
+ Hop into <http://localhost:7860/docs> for the OpenAPI docs.
16
+
17
+ ## Style
18
+
19
+ We use:
20
+
21
+ - **ruff** for linting and formatting (`make lint` / `make format`).
22
+ - **mypy** for type checking (`make type`).
23
+ - **pytest** + **pytest-asyncio** for tests (`make test`).
24
+
25
+ Pre-commit hooks are configured in `.pre-commit-config.yaml`. Install them
26
+ with `pre-commit install` to catch issues before pushing.
27
+
28
+ ## Pull requests
29
+
30
+ 1. Branch from `main`.
31
+ 2. Add tests for any new behavior — see `tests/unit` and `tests/integration`.
32
+ 3. Make sure `make lint test` passes locally.
33
+ 4. Update `README.md` / `ARCHITECTURE.md` when you change behavior.
34
+
35
+ ## Bug reports
36
+
37
+ Please include:
38
+
39
+ - Docsifer version (`docsifer/__init__.py:__version__`).
40
+ - Python and OS versions.
41
+ - A minimal reproduction (curl command or test snippet).
42
+ - Logs (with `DOCSIFER_LOG_JSON=false` for readability).
Dockerfile CHANGED
@@ -1,34 +1,96 @@
1
- # Use Python 3.10.9 as the base image for consistent runtime environment
2
- FROM python:3.10.9
3
-
4
- # Add metadata labels
5
- LABEL maintainer="lamhieu.vk@gmail.com"
6
- LABEL description="Docsifer: Efficient Data Conversion to Markdown using FastAPI and Hugging Face Transformers."
7
- LABEL version="1.0"
8
-
9
- # Setup non-root user for security
10
- RUN useradd -m -u 1000 user
11
- USER user
12
- ENV HOME=/home/user \
13
- PATH=/home/user/.local/bin:$PATH
14
-
15
- # Set working directory for all subsequent commands
16
- WORKDIR $HOME/app
17
-
18
- # Copy application files
19
- # Copy requirements first to leverage Docker cache
20
- COPY --chown=user requirements.txt .
21
- COPY --chown=user . .
22
-
23
- # Install Python dependencies
24
- # --no-cache-dir reduces image size
25
- # --upgrade ensures latest compatible versions
26
- RUN pip install --no-cache-dir --upgrade -r requirements.txt
27
-
28
- # Expose service port
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  EXPOSE 7860
30
 
31
- # Launch FastAPI application using uvicorn server
32
- # --host 0.0.0.0: Listen on all network interfaces
33
- # --port 7860: Run on port 7860
34
- CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # syntax=docker/dockerfile:1.6
2
+ # =============================================================================
3
+ # Docsifer — production image (multi-stage, slim, HF Spaces compatible)
4
+ # Build: docker build -t docsifer .
5
+ # Run: docker run --rm -p 7860:7860 --env-file .env docsifer
6
+ # =============================================================================
7
+
8
+ ARG PYTHON_VERSION=3.11
9
+
10
+ # -----------------------------------------------------------------------------
11
+ # Stage 1 — builder: compile wheels into /install
12
+ # -----------------------------------------------------------------------------
13
+ FROM python:${PYTHON_VERSION}-slim AS builder
14
+
15
+ ENV PYTHONDONTWRITEBYTECODE=1 \
16
+ PYTHONUNBUFFERED=1 \
17
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
18
+ PIP_NO_CACHE_DIR=0
19
+
20
+ RUN apt-get update \
21
+ && apt-get install -y --no-install-recommends \
22
+ build-essential \
23
+ libmagic1 \
24
+ ca-certificates \
25
+ && rm -rf /var/lib/apt/lists/*
26
+
27
+ WORKDIR /build
28
+ COPY requirements.txt .
29
+ RUN --mount=type=cache,target=/root/.cache/pip \
30
+ pip install --upgrade pip wheel \
31
+ && pip install --prefix=/install -r requirements.txt
32
+
33
+ # -----------------------------------------------------------------------------
34
+ # Stage 2 — runtime: minimal final image
35
+ # -----------------------------------------------------------------------------
36
+ FROM python:${PYTHON_VERSION}-slim AS runtime
37
+
38
+ LABEL org.opencontainers.image.title="Docsifer" \
39
+ org.opencontainers.image.description="Document → Markdown service powered by MarkItDown" \
40
+ org.opencontainers.image.source="https://github.com/lh0x00/docsifer" \
41
+ org.opencontainers.image.licenses="MIT"
42
+
43
+ ENV PYTHONDONTWRITEBYTECODE=1 \
44
+ PYTHONUNBUFFERED=1 \
45
+ PIP_NO_CACHE_DIR=1 \
46
+ HF_HOME=/home/app/.cache/huggingface \
47
+ XDG_CACHE_HOME=/home/app/.cache \
48
+ TMPDIR=/tmp \
49
+ DOCSIFER_TMP_DIR=/tmp \
50
+ DOCSIFER_LOG_JSON=true \
51
+ DOCSIFER_ENVIRONMENT=production \
52
+ PORT=7860 \
53
+ WEB_CONCURRENCY=2
54
+
55
+ RUN apt-get update \
56
+ && apt-get install -y --no-install-recommends \
57
+ ca-certificates \
58
+ libmagic1 \
59
+ ffmpeg \
60
+ curl \
61
+ && rm -rf /var/lib/apt/lists/*
62
+
63
+ # Hugging Face Spaces requires uid 1000 with write access to /home/user.
64
+ # We honor that convention so the image is portable.
65
+ RUN useradd --create-home --uid 1000 --shell /bin/bash app
66
+
67
+ # Bring in the pre-built site-packages from the builder
68
+ COPY --from=builder /install /usr/local
69
+
70
+ WORKDIR /home/app/app
71
+ COPY --chown=app:app . .
72
+
73
+ # Pre-create the cache dirs so HF Spaces / users with restricted FS still work
74
+ RUN mkdir -p "$HF_HOME" "$XDG_CACHE_HOME" \
75
+ && chown -R app:app /home/app
76
+
77
+ USER app
78
+
79
  EXPOSE 7860
80
 
81
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
82
+ CMD curl -fsS "http://127.0.0.1:${PORT}/v1/healthz" || exit 1
83
+
84
+ # Gunicorn + Uvicorn workers with deliberate request recycling so any slow
85
+ # library-level memory leak is bounded. ``WEB_CONCURRENCY`` and ``PORT`` can
86
+ # be tuned via environment variables.
87
+ CMD ["sh", "-c", "exec gunicorn docsifer.main:app \
88
+ --bind 0.0.0.0:${PORT} \
89
+ --worker-class uvicorn.workers.UvicornWorker \
90
+ --workers ${WEB_CONCURRENCY} \
91
+ --timeout 120 \
92
+ --graceful-timeout 30 \
93
+ --max-requests 500 \
94
+ --max-requests-jitter 50 \
95
+ --access-logfile - \
96
+ --error-logfile -"]
Makefile ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .PHONY: help install dev lint format type test cov run docker-build docker-run clean
2
+
3
+ PY ?= python3
4
+ APP := docsifer.main:app
5
+ PORT ?= 7860
6
+ HOST ?= 0.0.0.0
7
+
8
+ help: ## Show this help
9
+ @awk 'BEGIN {FS=":.*##"; printf "Targets:\n"} /^[a-zA-Z0-9_.-]+:.*##/ {printf " %-16s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
10
+
11
+ install: ## Install runtime + dev dependencies (using pip + requirements.txt)
12
+ $(PY) -m pip install --upgrade pip wheel
13
+ $(PY) -m pip install -r requirements.txt
14
+ $(PY) -m pip install pytest pytest-asyncio ruff mypy fakeredis
15
+
16
+ dev: install ## Install in editable / dev mode
17
+ $(PY) -m pip install -e .
18
+
19
+ lint: ## Lint with ruff
20
+ ruff check .
21
+
22
+ format: ## Format with ruff
23
+ ruff format .
24
+
25
+ type: ## Type-check with mypy
26
+ mypy
27
+
28
+ test: ## Run pytest
29
+ pytest -q
30
+
31
+ cov: ## Run pytest with coverage
32
+ pytest --cov=docsifer --cov-report=term-missing
33
+
34
+ run: ## Run the dev server (uvicorn, autoreload)
35
+ uvicorn $(APP) --host $(HOST) --port $(PORT) --reload
36
+
37
+ docker-build: ## Build the production Docker image
38
+ docker build -t docsifer:local .
39
+
40
+ docker-run: ## Run the Docker image on $(PORT)
41
+ docker run --rm -p $(PORT):7860 --env-file .env docsifer:local
42
+
43
+ clean: ## Remove caches and build artefacts
44
+ rm -rf .pytest_cache .ruff_cache .mypy_cache .coverage htmlcov dist build *.egg-info
45
+ find . -name __pycache__ -type d -prune -exec rm -rf {} +
README.md CHANGED
@@ -1,107 +1,133 @@
1
  ---
2
  title: Docsifer
3
- emoji: 👻 / 📚
4
- colorFrom: green
5
- colorTo: indigo
6
  sdk: docker
7
- app_file: app.py
8
  pinned: false
 
 
9
  ---
10
 
11
- # 📄 Docsifer: Efficient Data Conversion to Markdown
12
 
13
- **Docsifer** is a powerful FastAPI + Gradio service for converting various data formats (PDF, PowerPoint, Word, Excel, Images, Audio, HTML, etc.) to Markdown. It leverages the [MarkItDown](https://github.com/microsoft/markitdown) library and can optionally use LLMs (via OpenAI) for richer extraction (OCR, speech-to-text, etc.).
14
 
15
- ## Key Features
16
 
17
- - **Comprehensive Format Support**:
18
- - **PDF**: Extracts text and structure effectively.
19
- - **PowerPoint**: Converts slides into Markdown-friendly content.
20
- - **Word**: Processes `.docx` files with precision.
21
- - **Excel**: Extracts tabular data as Markdown tables.
22
- - **Images**: Reads **EXIF metadata** and applies **OCR** for text extraction.
23
- - **Audio**: Retrieves **EXIF metadata** and performs **speech transcription**.
24
- - **HTML**: Transforms web pages into Markdown.
25
- - **Text-Based Formats**: Handles CSV, JSON, XML with ease.
26
- - **ZIP Files**: Iterates over contents for batch processing.
27
- - **LLM Integration**: Leverages OpenAI's GPT-4 for enhanced extraction quality and contextual understanding.
28
- - **Efficient and Fast**: Optimized for speed while maintaining high accuracy.
29
- - **Easy Deployment**: Dockerized for hassle-free setup and scalability.
30
- - **Interactive Playground**: Test conversion processes interactively using a **Gradio-powered interface**.
31
- - **Usage Analytics**: Tracks token usage and access statistics via Upstash Redis.
32
 
33
- ## 🚀 Use Cases
 
 
 
34
 
35
- - **Knowledge Indexing**: Convert various document formats into Markdown for indexing and search.
36
- - **Text Analysis**: Prepare data for semantic analysis and NLP tasks.
37
- - **Content Transformation**: Simplify content preparation for blogs, documentation, or databases.
38
- - **Metadata Extraction**: Extract meaningful metadata from images and audio for categorization and tagging.
39
 
40
- ## 🛠️ Getting Started
41
 
42
- ### 1. Clone the Repository
43
 
44
- ```bash
45
- git clone https://github.com/lh0x00/docsifer.git
46
- cd docsifer
47
- ```
 
 
 
 
48
 
49
- ### 2. Build and Run with Docker
50
- Make sure Docker is installed and running on your machine.
51
  ```bash
52
- docker build -t lightweight-embeddings .
53
- docker run -p 7860:7860 lightweight-embeddings
 
 
 
 
 
 
54
  ```
55
 
56
- The API will now be accessible at `http://localhost:7860`.
57
 
58
- ## 📖 API Overview
59
 
60
- ### Endpoints
 
 
 
 
 
61
 
62
- - **`/v1/convert`**: Convert a file to Markdown. Supports both file uploads and file path inputs. Accepts optional OpenAI parameters to enable LLM-based enhancements.
63
- - **`/v1/stats`**: Retrieve usage statistics, including access counts and token usage.
64
 
65
- ### Interactive Docs
66
 
67
- - Visit the [Swagger UI](http://localhost:7860/docs) for detailed, interactive documentation.
68
- - Explore additional resources with [ReDoc](http://localhost:7860/redoc).
69
-
70
- ## 🔬 Playground
71
 
72
- ### Interactive Conversion
73
 
74
- - Test file conversion directly in the browser using the **Gradio interface**.
75
- - Simply visit `http://localhost:7860` after starting the server to access the playground.
 
 
 
76
 
77
- ### Features
78
 
79
- - **File Upload**: Upload a file directly or provide a local file path.
80
- - **OpenAI Integration**: Optionally provide OpenAI API details to enhance conversion with LLM capabilities.
81
- - **Conversion Result**: View the resulting Markdown output instantly.
82
- - **Usage Statistics**: Monitor access and token usage through the Gradio interface.
83
 
84
- ## 🌐 Resources
85
 
86
- - **Documentation**: [Explore full documentation](https://lamhieu-docsifer.hf.space/docs)
87
- - **Hugging Face Space**: [Try the live demo](https://huggingface.co/spaces/lh0x00/docsifer)
88
- - **GitHub Repository**: [View source code](https://github.com/lh0x00/docsifer)
89
 
90
- ## 💡 Why Docsifer?
 
 
 
 
 
 
 
 
 
91
 
92
- 1. **Versatile and Comprehensive**: Handles a wide range of formats, making it a one-stop solution for content conversion.
93
- 2. **AI-Powered**: Uses OpenAI's GPT-4 to enhance extraction accuracy and adapt to complex data structures.
94
- 3. **User-Friendly**: Offers intuitive APIs and a built-in interactive interface for experimentation.
95
- 4. **Scalable and Efficient**: Optimized for performance with Docker support and asynchronous processing.
96
- 5. **Transparent Analytics**: Tracks usage metrics to help monitor and manage service consumption.
97
 
98
- ## 👥 Contributors
 
 
 
 
 
 
 
 
 
 
 
99
 
100
- - **lamhieu / lh0x00** – Creator and Maintainer ([GitHub](https://github.com/lh0x00), [HuggingFace](https://huggingface.co/lamhieu))
101
 
102
- Contributions are welcome! Check out the [contribution guidelines](https://github.com/lh0x00/docsifer/blob/main/CONTRIBUTING.md).
103
 
104
- ## 📜 License
 
 
 
 
 
 
 
105
 
106
- This project is licensed under the **MIT License**. See the [LICENSE](https://github.com/lh0x00/docsifer/blob/main/LICENSE) file for details.
107
 
 
 
 
1
  ---
2
  title: Docsifer
3
+ emoji: 📚
4
+ colorFrom: indigo
5
+ colorTo: violet
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ license: mit
10
+ short_description: Convert documents into clean, LLM-ready Markdown.
11
  ---
12
 
13
+ <div align="center">
14
 
15
+ # 📚 Docsifer
16
 
17
+ **Convert documents into clean, LLM-ready Markdown.**
18
 
19
+ PDF · Word · PowerPoint · Excel · HTML · Audio · Image · CSV · JSON · ZIP
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue?logo=python&logoColor=white)](#)
22
+ [![FastAPI](https://img.shields.io/badge/FastAPI-0.110%2B-009688?logo=fastapi&logoColor=white)](#)
23
+ [![Docker](https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white)](#)
24
+ [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
25
 
26
+ </div>
 
 
 
27
 
28
+ ---
29
 
30
+ ## Features
31
 
32
+ - **Multi-format** — PDF, Office, audio (Whisper), images (vision), HTML, CSV, JSON, ZIP, and more — powered by [MarkItDown](https://github.com/microsoft/markitdown).
33
+ - **Optional LLM** — bring your own OpenAI-compatible key for OCR, transcription and structured layout extraction.
34
+ - **Production-grade** — bounded concurrency, per-IP fairness, memory watchdog, disk cleanup, circuit breaker.
35
+ - **Hardened** — SSRF guard, path-traversal sanitation, body-size limits, security headers.
36
+ - **Observable** — JSON logs with request id, `/v1/healthz`, `/v1/readyz`, `/v1/stats`.
37
+ - **Privacy-first** — files are processed in-memory and discarded immediately.
38
+
39
+ ## Quickstart
40
 
 
 
41
  ```bash
42
+ # Local
43
+ make install
44
+ cp .env.example .env
45
+ make run
46
+
47
+ # Docker
48
+ docker build -t docsifer .
49
+ docker run --rm -p 7860:7860 --env-file .env docsifer
50
  ```
51
 
52
+ Open <http://localhost:7860> for the UI or <http://localhost:7860/docs> for the API.
53
 
54
+ ## API
55
 
56
+ | Method | Path | Description |
57
+ | ------ | -------------- | --------------------------------- |
58
+ | POST | `/v1/convert` | Convert a file or URL to Markdown |
59
+ | GET | `/v1/stats` | Usage analytics snapshot |
60
+ | GET | `/v1/healthz` | Liveness probe |
61
+ | GET | `/v1/readyz` | Readiness probe |
62
 
63
+ ### Examples
 
64
 
65
+ Basic conversion:
66
 
67
+ ```bash
68
+ curl -X POST http://localhost:7860/v1/convert \
69
+ -F "file=@document.pdf"
70
+ ```
71
 
72
+ With LLM enhancement:
73
 
74
+ ```bash
75
+ curl -X POST http://localhost:7860/v1/convert \
76
+ -F "file=@page.html" \
77
+ -F 'openai={"api_key":"sk-...","model":"gpt-4o-mini"}'
78
+ ```
79
 
80
+ Convert a URL:
81
 
82
+ ```bash
83
+ curl -X POST http://localhost:7860/v1/convert \
84
+ -F "url=https://example.com/article"
85
+ ```
86
 
87
+ ## Configuration
88
 
89
+ All settings are environment-driven (prefix `DOCSIFER_`). See [`.env.example`](.env.example) for the full list. Common knobs:
 
 
90
 
91
+ | Variable | Default | Purpose |
92
+ | ------------------------------------- | ------- | ------------------------------------ |
93
+ | `DOCSIFER_MAX_UPLOAD_BYTES` | `10MB` | Hard upload limit |
94
+ | `DOCSIFER_MAX_CONCURRENT_CONVERSIONS` | `2` | Global parallelism |
95
+ | `DOCSIFER_MAX_QUEUE_DEPTH` | `10` | Reject 503 when exceeded |
96
+ | `DOCSIFER_MAX_PER_IP_CONCURRENT` | `1` | Per-IP fairness |
97
+ | `DOCSIFER_REQUEST_TIMEOUT_SEC` | `55` | Conversion timeout |
98
+ | `DOCSIFER_REDIS_URL` / `_TOKEN` | local | Upstash Redis for analytics |
99
+ | `DOCSIFER_URL_ALLOW_PRIVATE_NETWORKS` | `false` | Disable to block SSRF |
100
+ | `WEB_CONCURRENCY` *(Docker)* | `2` | Number of Gunicorn workers |
101
 
102
+ ## Architecture
 
 
 
 
103
 
104
+ ```
105
+ docsifer/
106
+ ├── api/ FastAPI layer — routes, schemas, middleware
107
+ ├── core/ Pure logic — converter, MIME, tokenizer, LLM cache
108
+ ├── analytics/ Lifespan-managed analytics (Upstash + in-memory)
109
+ ├── safety/ Anti-crash primitives (gate, limiter, watchdog, breaker)
110
+ ├── ui/ Optional Gradio playground
111
+ ├── config.py Pydantic settings
112
+ ├── exceptions.py
113
+ ├── logging_config.py
114
+ └── main.py App factory + lifespan
115
+ ```
116
 
117
+ See [`ARCHITECTURE.md`](ARCHITECTURE.md) for the full design notes.
118
 
119
+ ## Development
120
 
121
+ ```bash
122
+ make install # runtime + dev deps
123
+ make lint # ruff
124
+ make format # ruff format
125
+ make type # mypy
126
+ make test # pytest
127
+ make cov # pytest with coverage
128
+ ```
129
 
130
+ ## License
131
 
132
+ [MIT](LICENSE) © Lam Hieu — built on top of the wonderful
133
+ [MarkItDown](https://github.com/microsoft/markitdown).
app.py CHANGED
@@ -1 +1,5 @@
1
- from docsifer import app
 
 
 
 
 
1
+ """ASGI entry point for Hugging Face Spaces / uvicorn / gunicorn."""
2
+
3
+ from docsifer.main import app
4
+
5
+ __all__ = ["app"]
docsifer/__init__.py CHANGED
@@ -1,501 +1,6 @@
1
- # filename: __init__.py
2
 
3
- import json
4
- import logging
5
- import tempfile
6
- from typing import Tuple, Optional
7
 
8
- import gradio as gr
9
- import pandas as pd
10
- import requests
11
- from fastapi import FastAPI
12
- from fastapi.middleware.cors import CORSMiddleware
13
- from gradio.routes import mount_gradio_app
14
- from pathlib import Path
15
-
16
- # If you want to generate unique filenames, e.g. scuid:
17
- from scuid import scuid
18
-
19
-
20
- class LogFilter(logging.Filter):
21
- """
22
- A custom logging filter that only keeps log records containing '/v1'
23
- in the request path. This helps to filter out other logs and reduce noise.
24
- """
25
-
26
- def filter(self, record):
27
- # Only keep log records that contain "/v1" in the request path
28
- if record.args and len(record.args) >= 3:
29
- if "/v1" in str(record.args[2]):
30
- return True
31
- return False
32
-
33
-
34
- logger = logging.getLogger("uvicorn.access")
35
- logger.addFilter(LogFilter())
36
-
37
- __version__ = "1.0.0"
38
- __author__ = "lamhieu"
39
- __description__ = "Docsifer: Efficient Data Conversion to Markdown."
40
- __metadata__ = {
41
- "project": "Docsifer",
42
- "version": __version__,
43
- "description": (
44
- "Effortlessly convert various files to Markdown, including PDF, PowerPoint, Word, Excel, "
45
- "images, audio, HTML, JSON, CSV, XML, ZIP, and more."
46
- ),
47
- "docs": "https://lamhieu-docsifer.hf.space/docs",
48
- "github": "https://github.com/lh0x00/docsifer",
49
- "spaces": "https://huggingface.co/spaces/lh0x00/docsifer",
50
- }
51
-
52
- # Docsifer API Endpoints (can be replaced with your live URLs if desired)
53
- DOCSIFER_API_URL = "http://localhost:7860/v1/convert"
54
- DOCSIFER_STATS_URL = "http://localhost:7860/v1/stats"
55
-
56
- APP_DESCRIPTION = f"""
57
- # 📝 **Docsifer: Convert Your Documents to Markdown**
58
-
59
- Welcome to **Docsifer**, a specialized service that converts your files—like PDF, PPT, Word, Excel, images, audio, HTML, JSON, CSV, XML, ZIP, etc.—into **Markdown** using **MarkItDown** at the core. Optionally, you can leverage **LLMs** (OpenAI) for advanced text extraction.
60
-
61
- ### Features & Privacy
62
-
63
- - **Open Source**: The entire Docsifer codebase is publicly available for review and contribution.
64
- - **Efficient & Flexible**: Supports multiple file formats, ensuring quick and accurate Markdown conversion.
65
- - **Privacy-Focused**: We never store user data; all processing is temporary, with only minimal anonymous stats collected for call and token counts.
66
- - **Production-Ready**: Easy Docker deployment, interactive Gradio playground, and comprehensive REST API documentation.
67
- - **Community & Collaboration**: Contribute on [GitHub]({__metadata__["github"]}) or try it out on [Hugging Face Spaces]({__metadata__["spaces"]}).
68
-
69
- ### 🔗 Resources
70
- - [Documentation]({__metadata__["docs"]}) | [GitHub]({__metadata__["github"]}) | [Live Demo]({__metadata__["spaces"]})
71
- """
72
-
73
- app = FastAPI(
74
- title="Docsifer Service API",
75
- description=__description__,
76
- version=__version__,
77
- docs_url="/docs",
78
- redoc_url="/redoc",
79
- )
80
-
81
- # Configure CORS (Cross-Origin Resource Sharing)
82
- app.add_middleware(
83
- CORSMiddleware,
84
- allow_origins=["*"], # Adjust if needed for specific domains
85
- allow_credentials=True,
86
- allow_methods=["*"],
87
- allow_headers=["*"],
88
- )
89
-
90
- # Import and include your existing router (with /v1 endpoints)
91
- from .router import router
92
-
93
- app.include_router(router, prefix="/v1")
94
-
95
-
96
- def call_convert_api(
97
- file_obj: Optional[bytes],
98
- filename: str = "",
99
- url: Optional[str] = None,
100
- cleanup: bool = True,
101
- openai_base_url: Optional[str] = None,
102
- openai_api_key: Optional[str] = None,
103
- openai_model: Optional[str] = None,
104
- http_cookies: Optional[str] = None,
105
- ) -> Tuple[str, str]:
106
- """
107
- Call the /v1/convert endpoint, returning (markdown_content, md_file_path).
108
- - If there's an error, the first return value is an error message (str),
109
- the second is an empty string.
110
-
111
- Args:
112
- file_obj (Optional[bytes]): The raw file bytes to be sent. If None, 'url' is used.
113
- filename (str): Name of the file (will be posted to the endpoint).
114
- url (str, optional): URL to be converted (used only if file_obj is None).
115
- cleanup (bool): Whether to enable cleanup mode for HTML files.
116
- openai_base_url (str, optional): Base URL for OpenAI or compatible LLM.
117
- openai_api_key (str, optional): API key for the LLM.
118
- openai_model (str, optional): Model name to use for LLM-based extraction.
119
- http_cookies (str, optional): JSON-formatted string representing cookies for HTTP requests.
120
-
121
- Returns:
122
- (str, str):
123
- - markdown_content (str): The conversion result in Markdown form or an error message.
124
- - tmp_md_path (str): The path to the temporary .md file for download.
125
- """
126
- # Build the "openai" object
127
- openai_dict = {}
128
- if openai_api_key and openai_api_key.strip():
129
- openai_dict["api_key"] = openai_api_key
130
- if openai_base_url and openai_base_url.strip():
131
- openai_dict["base_url"] = openai_base_url
132
- if openai_model and openai_model.strip():
133
- openai_dict["model"] = openai_model
134
-
135
- # Build the "settings" object
136
- settings_dict = {"cleanup": cleanup}
137
-
138
- data = {
139
- # Must match the `Form(...)` fields named "openai" and "settings"
140
- "openai": json.dumps(openai_dict),
141
- "settings": json.dumps(settings_dict),
142
- }
143
-
144
- # If the user left the OpenAI fields blank, remove the `openai` key from data
145
- if len(openai_dict) <= 3:
146
- data.pop("openai")
147
-
148
- # Build the HTTP configuration object
149
- if http_cookies and http_cookies.strip():
150
- try:
151
- cookies_obj = json.loads(http_cookies)
152
- except Exception as e:
153
- return (f"❌ Invalid JSON for HTTP Cookies: {str(e)}", "")
154
- data["http"] = json.dumps({"cookies": cookies_obj})
155
-
156
- # Decide if we're sending a file or a URL
157
- files = {}
158
- if file_obj:
159
- # If file is provided, it takes priority
160
- files = {"file": (filename, file_obj)}
161
- data["url"] = "" # ensure 'url' is empty on the form
162
- elif url and url.strip():
163
- data["url"] = url.strip()
164
- else:
165
- return ("❌ Please upload a file or provide a URL.", "")
166
-
167
- # Perform the POST request
168
- try:
169
- response = requests.post(DOCSIFER_API_URL, files=files, data=data, timeout=30)
170
- except requests.exceptions.RequestException as e:
171
- return (f"❌ Network Error: {str(e)}", "")
172
-
173
- if response.status_code != 200:
174
- return (f"❌ API Error {response.status_code}: {response.text}", "")
175
-
176
- # Parse the API response
177
- try:
178
- converted = response.json()
179
- # Expected structure: { "filename": "...", "markdown": "..." }
180
- markdown_content = converted["markdown"]
181
- except Exception as e:
182
- return (f"❌ Error parsing JSON: {str(e)}", "")
183
-
184
- # Write the returned Markdown to a temp .md file
185
- with tempfile.NamedTemporaryFile(
186
- mode="w+", suffix=".md", dir="/tmp", delete=False
187
- ) as tmp_file:
188
- tmp_file.write(markdown_content)
189
- tmp_md_path = tmp_file.name
190
-
191
- return (markdown_content, tmp_md_path)
192
-
193
-
194
- def call_stats_api_df() -> Tuple[pd.DataFrame, pd.DataFrame]:
195
- """
196
- Call /v1/stats endpoint to retrieve analytics data and return two DataFrames:
197
- - access_df: Access statistics
198
- - tokens_df: Token usage statistics
199
-
200
- Raises:
201
- ValueError: If the stats endpoint fails or returns invalid data.
202
-
203
- Returns:
204
- Tuple[pd.DataFrame, pd.DataFrame]:
205
- (access_df, tokens_df) with columns ["Model", "Total", "Daily",
206
- "Weekly", "Monthly", "Yearly"].
207
- """
208
- try:
209
- response = requests.get(DOCSIFER_STATS_URL, timeout=10)
210
- except requests.exceptions.RequestException as e:
211
- raise ValueError(f"Failed to fetch stats: {str(e)}")
212
-
213
- if response.status_code != 200:
214
- raise ValueError(f"Failed to fetch stats: {response.text}")
215
-
216
- data = response.json()
217
- # Expected structure:
218
- # {
219
- # "access": { <period>: {"docsifer": count, ...}, ... },
220
- # "tokens": { <period>: {"docsifer": count, ...}, ... }
221
- # }
222
- access_data = data.get("access", {})
223
- tokens_data = data.get("tokens", {})
224
-
225
- def build_stats_df(bucket: dict) -> pd.DataFrame:
226
- """
227
- Helper function to transform a nested dictionary (by period, by model)
228
- into a tabular pandas DataFrame.
229
- """
230
- all_models = set()
231
- for period_key in ["total", "daily", "weekly", "monthly", "yearly"]:
232
- period_dict = bucket.get(period_key, {})
233
- all_models.update(period_dict.keys())
234
-
235
- result_dict = {
236
- "Model": [],
237
- "Total": [],
238
- "Daily": [],
239
- "Weekly": [],
240
- "Monthly": [],
241
- "Yearly": [],
242
- }
243
-
244
- for model in sorted(all_models):
245
- result_dict["Model"].append(model)
246
- result_dict["Total"].append(bucket.get("total", {}).get(model, 0))
247
- result_dict["Daily"].append(bucket.get("daily", {}).get(model, 0))
248
- result_dict["Weekly"].append(bucket.get("weekly", {}).get(model, 0))
249
- result_dict["Monthly"].append(bucket.get("monthly", {}).get(model, 0))
250
- result_dict["Yearly"].append(bucket.get("yearly", {}).get(model, 0))
251
-
252
- return pd.DataFrame(result_dict)
253
-
254
- access_df = build_stats_df(access_data)
255
- tokens_df = build_stats_df(tokens_data)
256
- return access_df, tokens_df
257
-
258
-
259
- def create_main_interface():
260
- """
261
- Create a Gradio Blocks interface that includes:
262
- 1) 'Conversion Playground' Tab:
263
- - File upload OR URL-based conversion
264
- - Optional OpenAI configuration and HTTP configuration
265
- - Convert button
266
- - Display of conversion result as Markdown
267
- - Downloadable .md file
268
- 2) 'Analytics Stats' Tab:
269
- - Button to fetch usage statistics
270
- - DataFrames for Access Stats and Token Stats
271
-
272
- Returns:
273
- Gradio Blocks instance that can be mounted into the FastAPI app.
274
- """
275
- with gr.Blocks(title="Docsifer: Convert to Markdown", theme="default") as demo:
276
- gr.Markdown(APP_DESCRIPTION)
277
-
278
- with gr.Tab("Conversion Playground"):
279
- gr.Markdown("### Convert your files or a URL to Markdown with Docsifer.")
280
-
281
- with gr.Row():
282
- # Left Column: File Upload, URL Input, Settings, Button
283
- with gr.Column():
284
- file_input = gr.File(
285
- label="Upload File (optional)",
286
- file_types=[
287
- ".html",
288
- ".htm",
289
- ".zip",
290
- ".jpg",
291
- ".jpeg",
292
- ".png",
293
- ".csv",
294
- ".ipynb",
295
- ".msg",
296
- ".xml",
297
- ".docx",
298
- ".json",
299
- ".pptx",
300
- ".xls",
301
- ".xlsx",
302
- ".pdf",
303
- ".mp3",
304
- ".wav",
305
- ],
306
- type="binary",
307
- )
308
-
309
- url_input = gr.Textbox(
310
- label="URL (optional)",
311
- placeholder="Enter a URL if no file is uploaded",
312
- )
313
-
314
- with gr.Accordion("OpenAI Configuration (Optional)", open=False):
315
- gr.Markdown(
316
- "Provide these if you'd like **LLM-assisted** extraction. "
317
- "Supports both OpenAI and OpenAI-compatible APIs. "
318
- "If left blank, basic conversion (no LLM) will be used."
319
- )
320
- openai_base_url = gr.Textbox(
321
- label="Base URL",
322
- placeholder="https://api.openai.com/v1",
323
- value="https://api.openai.com/v1",
324
- )
325
- openai_api_key = gr.Textbox(
326
- label="API Key",
327
- placeholder="sk-...",
328
- type="password",
329
- )
330
- openai_model = gr.Textbox(
331
- label="Model ID",
332
- placeholder="e.g. gpt-4o-mini",
333
- value="gpt-4o-mini",
334
- )
335
-
336
- with gr.Accordion("HTTP Configuration (Optional)", open=False):
337
- gr.Markdown(
338
- "Provide additional HTTP configuration. "
339
- "In particular, you can specify cookies as a JSON object to be included in the request."
340
- )
341
- http_cookies = gr.Textbox(
342
- label="Cookies",
343
- placeholder='e.g. {"session": "abcd1234"}',
344
- lines=3,
345
- )
346
-
347
- with gr.Accordion("Conversion Settings", open=True):
348
- gr.Markdown(
349
- "Enable to remove <style> tags or hidden elements "
350
- "from `.html` files before conversion."
351
- )
352
- cleanup_toggle = gr.Checkbox(
353
- label="Enable Cleanup",
354
- value=True,
355
- )
356
-
357
- convert_btn = gr.Button("Convert")
358
-
359
- gr.Markdown(
360
- """
361
- ### cURL Examples
362
-
363
- **Convert via File Upload (multipart/form-data)**:
364
- ```bash
365
- curl -X POST \\
366
- "https://lamhieu-docsifer.hf.space/v1/convert" \\
367
- -F "file=@/path/to/local/document.pdf" \\
368
- -F "openai={\\"api_key\\":\\"sk-xxxxx\\",\\"model\\":\\"gpt-4o-mini\\",\\"base_url\\":\\"https://api.openai.com/v1\\"}" \\
369
- -F "settings={\\"cleanup\\":true}"
370
- ```
371
-
372
- **Convert from a URL (no file)**:
373
- ```bash
374
- curl -X POST \\
375
- "https://lamhieu-docsifer.hf.space/v1/convert" \\
376
- -F "url=https://example.com/page.html" \\
377
- -F "openai={\\"api_key\\":\\"sk-xxxxx\\",\\"model\\":\\"gpt-4o-mini\\",\\"base_url\\":\\"https://api.openai.com/v1\\"}" \\
378
- -F "settings={\\"cleanup\\":true}"
379
- ```
380
- """
381
- )
382
-
383
- # Right Column: Conversion Result Display & Download
384
- with gr.Column():
385
- # Display the result as Markdown
386
- output_md = gr.Textbox(
387
- label="Markdown Preview",
388
- lines=40,
389
- interactive=True,
390
- show_copy_button=True,
391
- )
392
-
393
- # The user can still download the .md file
394
- download_file = gr.File(
395
- label="Download",
396
- interactive=False,
397
- visible=True,
398
- )
399
-
400
- # Callback function triggered by convert_btn.click
401
- def on_convert(
402
- file_bytes, url_str, base_url, api_key, model_id, cleanup, http_cookies
403
- ):
404
- """
405
- Converts the uploaded file or a URL to Markdown by calling the Docsifer API.
406
- Returns the resulting Markdown content and path to the temporary .md file for download.
407
-
408
- Args:
409
- file_bytes (bytes): The raw file content (None if not uploaded).
410
- url_str (str): The URL to convert (only used if file_bytes is None).
411
- base_url (str): The base URL for OpenAI or compatible LLM.
412
- api_key (str): The API key for the LLM.
413
- model_id (str): The model to use for the LLM.
414
- cleanup (bool): Whether to enable cleanup on HTML files.
415
- http_cookies (str): JSON-formatted string for HTTP cookies.
416
-
417
- Returns:
418
- (str, str):
419
- - The Markdown content or error message.
420
- - The path to the temp .md file for download.
421
- """
422
- if not file_bytes and not url_str:
423
- return "❌ Please upload a file or provide a URL.", None
424
-
425
- # Create a unique temporary filename if file is present
426
- unique_name = f"{scuid()}.tmp" if file_bytes else ""
427
-
428
- # Call the convert API with HTTP configuration
429
- markdown, temp_md_path = call_convert_api(
430
- file_obj=file_bytes,
431
- filename=unique_name,
432
- url=url_str,
433
- openai_base_url=base_url,
434
- openai_api_key=api_key,
435
- openai_model=model_id,
436
- cleanup=cleanup,
437
- http_cookies=http_cookies,
438
- )
439
-
440
- return markdown, temp_md_path
441
-
442
- # Link the on_convert function to the convert_btn
443
- convert_btn.click(
444
- fn=on_convert,
445
- inputs=[
446
- file_input,
447
- url_input,
448
- openai_base_url,
449
- openai_api_key,
450
- openai_model,
451
- cleanup_toggle,
452
- http_cookies,
453
- ],
454
- outputs=[output_md, download_file],
455
- )
456
-
457
- with gr.Tab("Analytics Stats"):
458
- gr.Markdown(
459
- "View Docsifer usage statistics (access count, token usage, etc.)"
460
- )
461
- stats_btn = gr.Button("Get Stats")
462
-
463
- access_df = gr.DataFrame(
464
- label="Access Stats",
465
- headers=["Model", "Total", "Daily", "Weekly", "Monthly", "Yearly"],
466
- interactive=False,
467
- )
468
- tokens_df = gr.DataFrame(
469
- label="Token Stats",
470
- headers=["Model", "Total", "Daily", "Weekly", "Monthly", "Yearly"],
471
- interactive=False,
472
- )
473
-
474
- # When the button is clicked, call_stats_api_df returns two dataframes
475
- stats_btn.click(
476
- fn=call_stats_api_df,
477
- inputs=[],
478
- outputs=[access_df, tokens_df],
479
- )
480
-
481
- return demo
482
-
483
-
484
- main_interface = create_main_interface()
485
- mount_gradio_app(app, main_interface, path="/")
486
-
487
-
488
- @app.on_event("startup")
489
- async def startup_event():
490
- """
491
- Logs a startup message when the Docsifer Service is starting.
492
- """
493
- logger.info("Docsifer Service is starting up...")
494
-
495
-
496
- @app.on_event("shutdown")
497
- async def shutdown_event():
498
- """
499
- Logs a shutdown message when the Docsifer Service is shutting down.
500
- """
501
- logger.info("Docsifer Service is shutting down.")
 
1
+ """Docsifer: efficient data conversion to Markdown."""
2
 
3
+ from .config import get_settings
 
 
 
4
 
5
+ __version__ = get_settings().app_version
6
+ __all__ = ["__version__"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docsifer/analytics.py DELETED
@@ -1,290 +0,0 @@
1
- # filename: analytics.py
2
-
3
- import logging
4
- import asyncio
5
- from upstash_redis import Redis as UpstashRedis
6
- from datetime import datetime
7
- from collections import defaultdict
8
- from typing import Dict
9
- from functools import partial
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
-
14
- class Analytics:
15
- def __init__(
16
- self, url: str, token: str, sync_interval: int = 60, max_retries: int = 5
17
- ):
18
- """
19
- Initializes the Analytics class with an Upstash Redis client (HTTP-based),
20
- wrapped in async methods by using run_in_executor.
21
-
22
- We maintain two dictionaries:
23
- - current_totals: absolute counters (loaded from Redis at startup).
24
- - new_increments: only the new usage since last sync.
25
-
26
- Both structures only track a single label "docsifer" for access/tokens.
27
- """
28
- self.url = url
29
- self.token = token
30
- self.sync_interval = sync_interval
31
- self.max_retries = max_retries
32
-
33
- # Create the synchronous Upstash Redis client over HTTP
34
- self.redis_client = self._create_redis_client()
35
-
36
- # current_totals: absolute counters from Redis
37
- self.current_totals = {
38
- "access": defaultdict(lambda: defaultdict(int)),
39
- "tokens": defaultdict(lambda: defaultdict(int)),
40
- }
41
- # new_increments: only new usage since the last successful sync
42
- self.new_increments = {
43
- "access": defaultdict(lambda: defaultdict(int)),
44
- "tokens": defaultdict(lambda: defaultdict(int)),
45
- }
46
-
47
- # Async lock to protect shared data
48
- self.lock = asyncio.Lock()
49
-
50
- # Start initial sync from Redis, then a periodic sync task
51
- asyncio.create_task(self._initialize())
52
-
53
- logger.info("Initialized Analytics with Upstash Redis: %s", url)
54
-
55
- def _create_redis_client(self) -> UpstashRedis:
56
- """Creates and returns a new Upstash Redis (synchronous) client."""
57
- return UpstashRedis(url=self.url, token=self.token)
58
-
59
- async def _initialize(self):
60
- """
61
- Fetch existing data from Redis into current_totals,
62
- then start the periodic sync task.
63
- """
64
- try:
65
- await self._sync_from_redis()
66
- logger.info("Initial sync from Upstash Redis completed successfully.")
67
- except Exception as e:
68
- logger.error("Error during initial Redis sync: %s", e)
69
-
70
- asyncio.create_task(self._start_sync_task())
71
-
72
- def _get_period_keys(self):
73
- """
74
- Returns day, week, month, year keys based on current UTC date.
75
- Also consider "total" if you want an all-time key.
76
-
77
- Example: ("2025-01-14", "2025-W02", "2025-01", "2025", "total")
78
- """
79
- now = datetime.utcnow()
80
- day_key = now.strftime("%Y-%m-%d")
81
- week_key = f"{now.year}-W{now.strftime('%U')}"
82
- month_key = now.strftime("%Y-%m")
83
- year_key = now.strftime("%Y")
84
- # For convenience, also track everything in "total".
85
- return day_key, week_key, month_key, year_key, "total"
86
-
87
- async def access(self, tokens: int):
88
- """
89
- Records an access and token usage for the "docsifer" label.
90
- This function updates both current_totals and new_increments.
91
- """
92
- day_key, week_key, month_key, year_key, total_key = self._get_period_keys()
93
-
94
- async with self.lock:
95
- # For each time period, increment "docsifer" usage
96
- for period in [day_key, week_key, month_key, year_key, total_key]:
97
- # Increase new usage
98
- self.new_increments["access"][period]["docsifer"] += 1
99
- self.new_increments["tokens"][period]["docsifer"] += tokens
100
-
101
- # Also update the absolute totals for immediate stats
102
- self.current_totals["access"][period]["docsifer"] += 1
103
- self.current_totals["tokens"][period]["docsifer"] += tokens
104
-
105
- async def stats(self) -> Dict[str, Dict[str, Dict[str, int]]]:
106
- """
107
- Returns a snapshot of current stats (absolute totals).
108
- We use current_totals, which is always up to date.
109
- """
110
- async with self.lock:
111
- return {
112
- "access": {
113
- period: dict(models)
114
- for period, models in self.current_totals["access"].items()
115
- },
116
- "tokens": {
117
- period: dict(models)
118
- for period, models in self.current_totals["tokens"].items()
119
- },
120
- }
121
-
122
- async def _sync_from_redis(self):
123
- """
124
- Pull existing data from Redis into current_totals and reset new_increments.
125
- We read "analytics:access:*" and "analytics:tokens:*" keys via SCAN.
126
- """
127
- loop = asyncio.get_running_loop()
128
-
129
- async with self.lock:
130
- # Reset both structures
131
- self.current_totals = {
132
- "access": defaultdict(lambda: defaultdict(int)),
133
- "tokens": defaultdict(lambda: defaultdict(int)),
134
- }
135
- self.new_increments = {
136
- "access": defaultdict(lambda: defaultdict(int)),
137
- "tokens": defaultdict(lambda: defaultdict(int)),
138
- }
139
-
140
- # ---------------------------
141
- # Load "access" data
142
- # ---------------------------
143
- cursor = 0
144
- while True:
145
- scan_result = await loop.run_in_executor(
146
- None,
147
- partial(
148
- self.redis_client.scan,
149
- cursor=cursor,
150
- match="analytics:access:*",
151
- count=1000,
152
- ),
153
- )
154
- cursor, keys = scan_result[0], scan_result[1]
155
-
156
- for key in keys:
157
- # key => "analytics:access:<period>"
158
- period = key.replace("analytics:access:", "")
159
- data = await loop.run_in_executor(
160
- None,
161
- partial(self.redis_client.hgetall, key),
162
- )
163
- for name_key, count_str in data.items():
164
- self.current_totals["access"][period][name_key] = int(count_str)
165
-
166
- if cursor == 0:
167
- break
168
-
169
- # ---------------------------
170
- # Load "tokens" data
171
- # ---------------------------
172
- cursor = 0
173
- while True:
174
- scan_result = await loop.run_in_executor(
175
- None,
176
- partial(
177
- self.redis_client.scan,
178
- cursor=cursor,
179
- match="analytics:tokens:*",
180
- count=1000,
181
- ),
182
- )
183
- cursor, keys = scan_result[0], scan_result[1]
184
-
185
- for key in keys:
186
- # key => "analytics:tokens:<period>"
187
- period = key.replace("analytics:tokens:", "")
188
- data = await loop.run_in_executor(
189
- None,
190
- partial(self.redis_client.hgetall, key),
191
- )
192
- for name_key, count_str in data.items():
193
- self.current_totals["tokens"][period][name_key] = int(count_str)
194
-
195
- if cursor == 0:
196
- break
197
-
198
- async def _sync_to_redis(self):
199
- """
200
- Push the new_increments to Redis with HINCRBY,
201
- then reset new_increments to zero if successful.
202
- """
203
- loop = asyncio.get_running_loop()
204
-
205
- async with self.lock:
206
- try:
207
- # Sync "access" increments
208
- for period, models in self.new_increments["access"].items():
209
- redis_key = f"analytics:access:{period}"
210
- for name_key, count_val in models.items():
211
- if count_val != 0:
212
- await loop.run_in_executor(
213
- None,
214
- partial(
215
- self.redis_client.hincrby,
216
- redis_key,
217
- name_key,
218
- count_val,
219
- ),
220
- )
221
-
222
- # Sync "tokens" increments
223
- for period, models in self.new_increments["tokens"].items():
224
- redis_key = f"analytics:tokens:{period}"
225
- for name_key, count_val in models.items():
226
- if count_val != 0:
227
- await loop.run_in_executor(
228
- None,
229
- partial(
230
- self.redis_client.hincrby,
231
- redis_key,
232
- name_key,
233
- count_val,
234
- ),
235
- )
236
-
237
- logger.info("Analytics data synced to Upstash Redis.")
238
-
239
- # Reset new_increments only
240
- self.new_increments = {
241
- "access": defaultdict(lambda: defaultdict(int)),
242
- "tokens": defaultdict(lambda: defaultdict(int)),
243
- }
244
-
245
- except Exception as e:
246
- logger.error("Error syncing to Redis: %s", e)
247
- raise e
248
-
249
- async def _start_sync_task(self):
250
- """Periodically sync local increments to Redis."""
251
- while True:
252
- await asyncio.sleep(self.sync_interval)
253
- try:
254
- await self._sync_to_redis()
255
- except Exception as e:
256
- logger.error("Error during scheduled sync: %s", e)
257
- await self._handle_redis_reconnection()
258
-
259
- async def _handle_redis_reconnection(self):
260
- """
261
- Attempts to reconnect to Redis if connection fails (HTTP-based, stateless).
262
- """
263
- loop = asyncio.get_running_loop()
264
- retry_count = 0
265
- delay = 1
266
-
267
- while retry_count < self.max_retries:
268
- try:
269
- logger.info(
270
- "Attempting Redis reconnection (attempt %d)...", retry_count + 1
271
- )
272
- await loop.run_in_executor(None, self.redis_client.close)
273
- self.redis_client = self._create_redis_client()
274
- logger.info("Reconnected to Redis successfully.")
275
- return
276
- except Exception as e:
277
- logger.error("Reconnection attempt %d failed: %s", retry_count + 1, e)
278
- retry_count += 1
279
- await asyncio.sleep(delay)
280
- delay *= 2
281
-
282
- logger.critical("Max reconnection attempts reached. Redis is unavailable.")
283
-
284
- async def close(self):
285
- """
286
- Close the Upstash Redis client (though it's stateless over HTTP).
287
- """
288
- loop = asyncio.get_running_loop()
289
- await loop.run_in_executor(None, self.redis_client.close)
290
- logger.info("Redis client closed.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docsifer/analytics/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Analytics package."""
2
+
3
+ from .periods import period_keys
4
+ from .service import AnalyticsService
5
+ from .store import AnalyticsStore, InMemoryStore, UpstashStore
6
+
7
+ __all__ = [
8
+ "AnalyticsService",
9
+ "AnalyticsStore",
10
+ "InMemoryStore",
11
+ "UpstashStore",
12
+ "period_keys",
13
+ ]
docsifer/analytics/periods.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Period-key helpers for analytics buckets.
2
+
3
+ Uses ISO 8601 week numbering (``%G-W%V``) so values at year boundaries are
4
+ consistent across years (fixes the legacy ``%U`` bug).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime, timezone
10
+
11
+
12
+ def period_keys(now: datetime | None = None) -> tuple[str, str, str, str, str]:
13
+ """Return ``(day, week, month, year, "total")`` for ``now`` (UTC by default)."""
14
+ if now is None:
15
+ now = datetime.now(tz=timezone.utc)
16
+ elif now.tzinfo is None:
17
+ now = now.replace(tzinfo=timezone.utc)
18
+
19
+ day_key = now.strftime("%Y-%m-%d")
20
+ week_key = now.strftime("%G-W%V") # ISO week
21
+ month_key = now.strftime("%Y-%m")
22
+ year_key = now.strftime("%Y")
23
+ return day_key, week_key, month_key, year_key, "total"
docsifer/analytics/service.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lifespan-managed analytics service.
2
+
3
+ Improvements over the legacy implementation:
4
+ - Lifespan-managed (no ``asyncio.create_task`` at import time → fixes Bug A5).
5
+ - ``new_increments`` accumulator is **never reset on read paths** so concurrent
6
+ ``access()`` calls during the initial load are no longer lost (Bug A5/A6).
7
+ - Failed Redis syncs keep the increments queued for the next attempt; an
8
+ optional reconnection backoff retries the sync immediately when possible.
9
+ - ``stats()`` returns an immutable snapshot updated atomically after every
10
+ ``access()`` so reads are lock-free (Section C.4).
11
+ - Pluggable :class:`AnalyticsStore` enables in-memory mode when Redis is
12
+ unavailable (Section N.10).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import contextlib
19
+ import copy
20
+ import logging
21
+ from collections import defaultdict
22
+ from typing import Any
23
+
24
+ from .periods import period_keys
25
+ from .store import AnalyticsStore, NestedCounter
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ def _empty_counter() -> NestedCounter:
31
+ return {
32
+ "access": defaultdict(lambda: defaultdict(int)),
33
+ "tokens": defaultdict(lambda: defaultdict(int)),
34
+ }
35
+
36
+
37
+ class AnalyticsService:
38
+ """Async-friendly analytics aggregator backed by a pluggable store."""
39
+
40
+ def __init__(
41
+ self,
42
+ *,
43
+ store: AnalyticsStore,
44
+ sync_interval_sec: int = 1800,
45
+ max_retries: int = 5,
46
+ label: str = "docsifer",
47
+ ) -> None:
48
+ self._store = store
49
+ self._sync_interval = max(1, sync_interval_sec)
50
+ self._max_retries = max(0, max_retries)
51
+ self._label = label
52
+
53
+ self._totals: NestedCounter = _empty_counter()
54
+ self._pending: NestedCounter = _empty_counter()
55
+ self._snapshot: dict[str, dict[str, dict[str, int]]] = {
56
+ "access": {},
57
+ "tokens": {},
58
+ }
59
+ self._lock = asyncio.Lock()
60
+ self._sync_task: asyncio.Task[None] | None = None
61
+ self._stopped = asyncio.Event()
62
+ self._healthy = True
63
+
64
+ # ------------------------------------------------------------------
65
+ # Lifecycle
66
+ # ------------------------------------------------------------------
67
+ async def start(self) -> None:
68
+ """Load existing data from the store and launch the sync loop."""
69
+ try:
70
+ initial = await self._store.load_all()
71
+ async with self._lock:
72
+ # Merge instead of replacing so any access() events that arrived
73
+ # while loading are preserved.
74
+ for metric, periods in initial.items():
75
+ for period, labels in periods.items():
76
+ for label, count in labels.items():
77
+ self._totals[metric][period][label] += int(count)
78
+ self._refresh_snapshot()
79
+ logger.info("Analytics initial load complete")
80
+ except Exception as exc:
81
+ logger.warning("Analytics initial load failed: %s", exc)
82
+ self._healthy = False
83
+
84
+ self._sync_task = asyncio.create_task(self._sync_loop(), name="analytics-sync")
85
+
86
+ async def stop(self) -> None:
87
+ """Flush pending increments and stop the background loop."""
88
+ self._stopped.set()
89
+ if self._sync_task is not None:
90
+ self._sync_task.cancel()
91
+ with contextlib.suppress(asyncio.CancelledError):
92
+ await self._sync_task
93
+ try:
94
+ await self._flush_once()
95
+ except Exception as exc:
96
+ logger.warning("Final analytics flush failed: %s", exc)
97
+ with contextlib.suppress(Exception):
98
+ await self._store.close()
99
+
100
+ # ------------------------------------------------------------------
101
+ # Public API
102
+ # ------------------------------------------------------------------
103
+ async def access(self, tokens: int, *, label: str | None = None) -> None:
104
+ """Record one access plus ``tokens`` token usage for ``label``."""
105
+ label = label or self._label
106
+ keys = period_keys()
107
+ async with self._lock:
108
+ for period in keys:
109
+ self._pending["access"][period][label] += 1
110
+ self._pending["tokens"][period][label] += int(tokens)
111
+ self._totals["access"][period][label] += 1
112
+ self._totals["tokens"][period][label] += int(tokens)
113
+ self._refresh_snapshot()
114
+
115
+ async def stats(self) -> dict[str, Any]:
116
+ """Return an immutable snapshot of the current totals."""
117
+ snap = self._snapshot
118
+ return {
119
+ "access": snap["access"],
120
+ "tokens": snap["tokens"],
121
+ "healthy": self._healthy,
122
+ }
123
+
124
+ async def ping(self) -> bool:
125
+ return await self._store.ping()
126
+
127
+ @property
128
+ def healthy(self) -> bool:
129
+ return self._healthy
130
+
131
+ # ------------------------------------------------------------------
132
+ # Internal
133
+ # ------------------------------------------------------------------
134
+ def _refresh_snapshot(self) -> None:
135
+ """Build a new immutable snapshot dict from the live counters."""
136
+ self._snapshot = {
137
+ "access": {p: dict(m) for p, m in self._totals["access"].items()},
138
+ "tokens": {p: dict(m) for p, m in self._totals["tokens"].items()},
139
+ }
140
+
141
+ async def _flush_once(self) -> None:
142
+ async with self._lock:
143
+ if not self._has_pending(self._pending):
144
+ return
145
+ payload = copy.deepcopy(self._pending)
146
+ # Optimistically clear; restore on failure
147
+ cleared = self._pending
148
+ self._pending = _empty_counter()
149
+ try:
150
+ await self._store.apply_increments(payload)
151
+ self._healthy = True
152
+ except Exception:
153
+ # Restore the pending payload so we retry next tick
154
+ async with self._lock:
155
+ for metric, periods in payload.items():
156
+ for period, labels in periods.items():
157
+ for label, count in labels.items():
158
+ self._pending[metric][period][label] += count
159
+ _ = cleared # silence "unused"
160
+ self._healthy = False
161
+ raise
162
+
163
+ async def _sync_loop(self) -> None:
164
+ retries = 0
165
+ backoff = 1.0
166
+ while not self._stopped.is_set():
167
+ try:
168
+ await asyncio.wait_for(
169
+ self._stopped.wait(), timeout=self._sync_interval
170
+ )
171
+ return # stopped
172
+ except asyncio.TimeoutError:
173
+ pass
174
+
175
+ try:
176
+ await self._flush_once()
177
+ retries = 0
178
+ backoff = 1.0
179
+ except Exception as exc:
180
+ logger.warning("Analytics sync failed: %s", exc)
181
+ if retries < self._max_retries:
182
+ retries += 1
183
+ await asyncio.sleep(min(backoff, 60.0))
184
+ backoff *= 2
185
+
186
+ @staticmethod
187
+ def _has_pending(counter: NestedCounter) -> bool:
188
+ for periods in counter.values():
189
+ for labels in periods.values():
190
+ for count in labels.values():
191
+ if count:
192
+ return True
193
+ return False
docsifer/analytics/store.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Storage backends for analytics counters.
2
+
3
+ The :class:`AnalyticsStore` protocol abstracts the persistence layer so the
4
+ :class:`docsifer.analytics.service.AnalyticsService` can switch between
5
+ Upstash Redis (production) and an :class:`InMemoryStore` (tests / local dev /
6
+ graceful-degraded mode when Redis is unavailable).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ from collections import defaultdict
14
+ from functools import partial
15
+ from typing import Protocol, runtime_checkable
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # Top-level metric → period → label → count
20
+ NestedCounter = dict[str, dict[str, dict[str, int]]]
21
+
22
+
23
+ def _empty_counter() -> NestedCounter:
24
+ return {
25
+ "access": defaultdict(lambda: defaultdict(int)),
26
+ "tokens": defaultdict(lambda: defaultdict(int)),
27
+ }
28
+
29
+
30
+ @runtime_checkable
31
+ class AnalyticsStore(Protocol):
32
+ async def load_all(self) -> NestedCounter: ...
33
+
34
+ async def apply_increments(self, increments: NestedCounter) -> None: ...
35
+
36
+ async def ping(self) -> bool: ...
37
+
38
+ async def close(self) -> None: ...
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # In-memory store (fallback / tests)
43
+ # ---------------------------------------------------------------------------
44
+ class InMemoryStore:
45
+ def __init__(self) -> None:
46
+ self._data: NestedCounter = _empty_counter()
47
+
48
+ async def load_all(self) -> NestedCounter:
49
+ return _empty_counter()
50
+
51
+ async def apply_increments(self, increments: NestedCounter) -> None:
52
+ for metric, periods in increments.items():
53
+ for period, models in periods.items():
54
+ for label, count in models.items():
55
+ if count:
56
+ self._data[metric][period][label] += count
57
+
58
+ async def ping(self) -> bool:
59
+ return True
60
+
61
+ async def close(self) -> None: # pragma: no cover - nothing to do
62
+ return None
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Upstash Redis (HTTP) store
67
+ # ---------------------------------------------------------------------------
68
+ class UpstashStore:
69
+ """Upstash Redis-backed store using batched pipeline writes (Section N.9)."""
70
+
71
+ KEY_PREFIX = "analytics"
72
+
73
+ def __init__(self, url: str, token: str | None) -> None:
74
+ from upstash_redis import Redis as UpstashRedis # lazy import
75
+
76
+ self._UpstashRedis = UpstashRedis
77
+ self._url = url
78
+ self._token = token
79
+ self._client = self._make_client()
80
+
81
+ def _make_client(self): # type: ignore[no-untyped-def]
82
+ return self._UpstashRedis(url=self._url, token=self._token)
83
+
84
+ async def load_all(self) -> NestedCounter:
85
+ loop = asyncio.get_running_loop()
86
+ result: NestedCounter = _empty_counter()
87
+ for metric in ("access", "tokens"):
88
+ cursor = 0
89
+ pattern = f"{self.KEY_PREFIX}:{metric}:*"
90
+ while True:
91
+ scan = await loop.run_in_executor(
92
+ None,
93
+ partial(self._client.scan, cursor=cursor, match=pattern, count=1000),
94
+ )
95
+ cursor = scan[0]
96
+ keys = scan[1] or []
97
+ for key in keys:
98
+ period = key.split(":", 2)[-1]
99
+ data = await loop.run_in_executor(
100
+ None, partial(self._client.hgetall, key)
101
+ )
102
+ for label, count in (data or {}).items():
103
+ try:
104
+ result[metric][period][label] = int(count)
105
+ except (TypeError, ValueError):
106
+ continue
107
+ if int(cursor) == 0:
108
+ break
109
+ return result
110
+
111
+ async def apply_increments(self, increments: NestedCounter) -> None:
112
+ loop = asyncio.get_running_loop()
113
+ ops: list = []
114
+ for metric, periods in increments.items():
115
+ for period, models in periods.items():
116
+ key = f"{self.KEY_PREFIX}:{metric}:{period}"
117
+ for label, count in models.items():
118
+ if count:
119
+ ops.append((key, label, int(count)))
120
+ if not ops:
121
+ return
122
+
123
+ # Prefer pipeline when available (single HTTP round trip)
124
+ pipeline = getattr(self._client, "pipeline", None)
125
+ if callable(pipeline):
126
+ def _run() -> None:
127
+ pipe = pipeline()
128
+ for key, label, count in ops:
129
+ pipe.hincrby(key, label, count)
130
+ pipe.execute()
131
+
132
+ await loop.run_in_executor(None, _run)
133
+ return
134
+
135
+ # Fallback: parallel single-op calls
136
+ await asyncio.gather(
137
+ *(
138
+ loop.run_in_executor(
139
+ None, partial(self._client.hincrby, key, label, count)
140
+ )
141
+ for key, label, count in ops
142
+ )
143
+ )
144
+
145
+ async def ping(self) -> bool:
146
+ loop = asyncio.get_running_loop()
147
+ try:
148
+ await loop.run_in_executor(None, self._client.ping)
149
+ return True
150
+ except Exception as exc:
151
+ logger.warning("Upstash ping failed: %s", exc)
152
+ return False
153
+
154
+ async def close(self) -> None:
155
+ loop = asyncio.get_running_loop()
156
+ with logger_suppress("Upstash close failed"):
157
+ await loop.run_in_executor(None, self._client.close)
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Helper
162
+ # ---------------------------------------------------------------------------
163
+ class logger_suppress: # noqa: N801 - tiny utility, lower-case name for context-manager idiom
164
+ """Context manager that logs (rather than raises) any exception."""
165
+
166
+ def __init__(self, message: str) -> None:
167
+ self._message = message
168
+
169
+ def __enter__(self) -> "logger_suppress":
170
+ return self
171
+
172
+ def __exit__(self, exc_type, exc, tb) -> bool: # type: ignore[no-untyped-def]
173
+ if exc_type is not None:
174
+ logger.warning("%s: %s", self._message, exc)
175
+ return True
176
+
177
+ async def __aenter__(self) -> "logger_suppress":
178
+ return self
179
+
180
+ async def __aexit__(self, exc_type, exc, tb) -> bool: # type: ignore[no-untyped-def]
181
+ if exc_type is not None:
182
+ logger.warning("%s: %s", self._message, exc)
183
+ return True
docsifer/api/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """HTTP/API layer."""
docsifer/api/deps.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI dependency providers wired against the app state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import Depends, Request
6
+
7
+ from ..analytics import AnalyticsService
8
+ from ..config import Settings, get_settings
9
+ from ..core.service import DocsiferService
10
+ from ..safety import ConversionGate, PerIPLimiter, ResourceGuard
11
+
12
+
13
+ def settings_dep() -> Settings:
14
+ return get_settings()
15
+
16
+
17
+ def converter_dep(request: Request) -> DocsiferService:
18
+ return request.app.state.converter # type: ignore[no-any-return]
19
+
20
+
21
+ def analytics_dep(request: Request) -> AnalyticsService:
22
+ return request.app.state.analytics # type: ignore[no-any-return]
23
+
24
+
25
+ def conversion_gate_dep(request: Request) -> ConversionGate:
26
+ return request.app.state.conversion_gate # type: ignore[no-any-return]
27
+
28
+
29
+ def per_ip_limiter_dep(request: Request) -> PerIPLimiter:
30
+ return request.app.state.per_ip_limiter # type: ignore[no-any-return]
31
+
32
+
33
+ def resource_guard_dep(request: Request) -> ResourceGuard:
34
+ return request.app.state.resource_guard # type: ignore[no-any-return]
35
+
36
+
37
+ def client_ip_dep(request: Request) -> str:
38
+ forwarded = request.headers.get("x-forwarded-for")
39
+ if forwarded:
40
+ return forwarded.split(",")[0].strip()
41
+ real = request.headers.get("x-real-ip")
42
+ if real:
43
+ return real.strip()
44
+ return request.client.host if request.client else "unknown"
45
+
46
+
47
+ __all__ = [
48
+ "Depends",
49
+ "analytics_dep",
50
+ "client_ip_dep",
51
+ "conversion_gate_dep",
52
+ "converter_dep",
53
+ "per_ip_limiter_dep",
54
+ "resource_guard_dep",
55
+ "settings_dep",
56
+ ]
docsifer/api/error_handlers.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Translate domain and unexpected exceptions into safe JSON responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from fastapi import FastAPI, HTTPException, Request
9
+ from fastapi.exceptions import RequestValidationError
10
+ from fastapi.responses import JSONResponse
11
+
12
+ from ..exceptions import DocsiferError
13
+ from ..logging_config import request_id_var
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _payload(
19
+ *,
20
+ error: str,
21
+ message: str,
22
+ details: dict[str, Any] | None = None,
23
+ ) -> dict[str, Any]:
24
+ body: dict[str, Any] = {
25
+ "error": error,
26
+ "message": message,
27
+ "request_id": request_id_var.get(),
28
+ }
29
+ if details:
30
+ body["details"] = details
31
+ return body
32
+
33
+
34
+ async def _docsifer_handler(request: Request, exc: DocsiferError) -> JSONResponse:
35
+ log = logger.warning if exc.status_code < 500 else logger.exception
36
+ log(
37
+ "%s on %s: %s",
38
+ exc.__class__.__name__,
39
+ request.url.path,
40
+ exc,
41
+ )
42
+ return JSONResponse(
43
+ status_code=exc.status_code,
44
+ content=_payload(
45
+ error=exc.__class__.__name__,
46
+ message=exc.public_message,
47
+ details=exc.details or None,
48
+ ),
49
+ )
50
+
51
+
52
+ async def _http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
53
+ return JSONResponse(
54
+ status_code=exc.status_code,
55
+ content=_payload(
56
+ error="HTTPException",
57
+ message=str(exc.detail) if exc.detail else "HTTP error",
58
+ ),
59
+ )
60
+
61
+
62
+ async def _validation_handler(
63
+ request: Request, exc: RequestValidationError
64
+ ) -> JSONResponse:
65
+ return JSONResponse(
66
+ status_code=422,
67
+ content=_payload(
68
+ error="ValidationError",
69
+ message="Request validation failed",
70
+ details={"errors": exc.errors()},
71
+ ),
72
+ )
73
+
74
+
75
+ async def _fallback_handler(request: Request, exc: Exception) -> JSONResponse:
76
+ logger.exception("Unhandled exception on %s", request.url.path)
77
+ return JSONResponse(
78
+ status_code=500,
79
+ content=_payload(
80
+ error="InternalServerError",
81
+ message="An unexpected error occurred",
82
+ ),
83
+ )
84
+
85
+
86
+ def register_exception_handlers(app: FastAPI) -> None:
87
+ app.add_exception_handler(DocsiferError, _docsifer_handler) # type: ignore[arg-type]
88
+ app.add_exception_handler(HTTPException, _http_exception_handler)
89
+ app.add_exception_handler(RequestValidationError, _validation_handler) # type: ignore[arg-type]
90
+ app.add_exception_handler(Exception, _fallback_handler)
docsifer/api/middleware.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Custom middleware: request id, body size limit, security headers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ from typing import Awaitable, Callable
7
+
8
+ from fastapi import FastAPI, Request, Response
9
+ from fastapi.responses import JSONResponse
10
+
11
+ from ..config import Settings
12
+ from ..logging_config import request_id_var
13
+
14
+ ASGICall = Callable[[Request], Awaitable[Response]]
15
+
16
+ REQUEST_ID_HEADER = "X-Request-ID"
17
+ _SECURITY_HEADERS = {
18
+ "X-Content-Type-Options": "nosniff",
19
+ "X-Frame-Options": "DENY",
20
+ "Referrer-Policy": "no-referrer",
21
+ "X-XSS-Protection": "0",
22
+ }
23
+
24
+
25
+ async def _request_id_middleware(request: Request, call_next: ASGICall) -> Response:
26
+ rid = request.headers.get(REQUEST_ID_HEADER) or secrets.token_hex(8)
27
+ token = request_id_var.set(rid)
28
+ try:
29
+ response = await call_next(request)
30
+ finally:
31
+ request_id_var.reset(token)
32
+ response.headers[REQUEST_ID_HEADER] = rid
33
+ return response
34
+
35
+
36
+ def _make_body_limit_middleware(
37
+ max_bytes: int,
38
+ ) -> Callable[[Request, ASGICall], Awaitable[Response]]:
39
+ async def _body_limit(request: Request, call_next: ASGICall) -> Response:
40
+ cl = request.headers.get("content-length")
41
+ if cl is not None:
42
+ try:
43
+ content_length = int(cl)
44
+ except ValueError:
45
+ content_length = None
46
+ if content_length is not None and content_length > max_bytes:
47
+ return JSONResponse(
48
+ status_code=413,
49
+ content={
50
+ "error": "PayloadTooLargeError",
51
+ "message": "Payload too large",
52
+ "details": {"max_bytes": max_bytes},
53
+ "request_id": request_id_var.get(),
54
+ },
55
+ )
56
+ return await call_next(request)
57
+
58
+ return _body_limit
59
+
60
+
61
+ async def _security_headers_middleware(request: Request, call_next: ASGICall) -> Response:
62
+ response = await call_next(request)
63
+ for header, value in _SECURITY_HEADERS.items():
64
+ response.headers.setdefault(header, value)
65
+ return response
66
+
67
+
68
+ def register_middleware(app: FastAPI, settings: Settings) -> None:
69
+ """Wire middleware in the correct order.
70
+
71
+ Order (outermost first): request-id → body-limit → security-headers.
72
+ Starlette executes them in *registration* order on the way in and reverse
73
+ order on the way out, so the first one we add runs outermost.
74
+ """
75
+ app.middleware("http")(_request_id_middleware)
76
+ app.middleware("http")(_make_body_limit_middleware(settings.max_upload_bytes))
77
+ if settings.enable_security_headers:
78
+ app.middleware("http")(_security_headers_middleware)
docsifer/api/schemas.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic request/response models for the public API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+
10
+ class OpenAIConfig(BaseModel):
11
+ model_config = ConfigDict(extra="ignore")
12
+
13
+ api_key: str | None = None
14
+ base_url: str | None = None
15
+ model: str | None = None
16
+
17
+ def is_enabled(self) -> bool:
18
+ return bool(self.api_key and self.api_key.strip())
19
+
20
+ def to_dict(self) -> dict[str, Any] | None:
21
+ if not self.is_enabled():
22
+ return None
23
+ out: dict[str, Any] = {"api_key": self.api_key}
24
+ if self.base_url:
25
+ out["base_url"] = self.base_url
26
+ if self.model:
27
+ out["model"] = self.model
28
+ return out
29
+
30
+
31
+ class HTTPConfig(BaseModel):
32
+ model_config = ConfigDict(extra="ignore")
33
+
34
+ cookies: dict[str, str] | None = None
35
+ headers: dict[str, str] | None = None
36
+
37
+ def to_dict(self) -> dict[str, Any] | None:
38
+ out: dict[str, Any] = {}
39
+ if self.cookies:
40
+ out["cookies"] = self.cookies
41
+ if self.headers:
42
+ out["headers"] = self.headers
43
+ return out or None
44
+
45
+
46
+ class ConvertSettings(BaseModel):
47
+ model_config = ConfigDict(extra="ignore")
48
+
49
+ cleanup: bool = Field(
50
+ default=True,
51
+ description="Strip <style>, <script> and hidden elements from HTML before conversion.",
52
+ )
53
+
54
+
55
+ class ConvertResponse(BaseModel):
56
+ filename: str
57
+ markdown: str
58
+
59
+
60
+ class StatsResponse(BaseModel):
61
+ access: dict[str, dict[str, int]] = Field(default_factory=dict)
62
+ tokens: dict[str, dict[str, int]] = Field(default_factory=dict)
63
+ healthy: bool = True
64
+
65
+
66
+ class HealthResponse(BaseModel):
67
+ status: str
68
+ version: str | None = None
69
+ details: dict[str, Any] | None = None
70
+
71
+
72
+ class ErrorResponse(BaseModel):
73
+ error: str
74
+ message: str
75
+ request_id: str | None = None
76
+ details: dict[str, Any] | None = None
docsifer/api/v1/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """v1 API routers."""
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from .convert import router as convert_router
6
+ from .health import router as health_router
7
+ from .stats import router as stats_router
8
+
9
+ router = APIRouter()
10
+ router.include_router(convert_router)
11
+ router.include_router(stats_router)
12
+ router.include_router(health_router)
13
+
14
+ __all__ = ["router"]
docsifer/api/v1/convert.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``POST /v1/convert`` endpoint.
2
+
3
+ Implements all the safety controls from Section N (admission, per-IP
4
+ fairness, resource guard) plus the bug fixes from Section A:
5
+
6
+ - A3 path-traversal: filename is sanitized via ``Path(name).name``.
7
+ - A4 SSRF: URLs are validated by :func:`validate_url`.
8
+ - F4 body size: enforced by middleware.
9
+ - F5 MIME allowlist: enforced here against ``settings.allowed_extensions``.
10
+ - F6 leak: errors mapped to :class:`DocsiferError` subclasses.
11
+ - C6 blocking I/O: file is streamed to disk in a worker thread.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import logging
18
+ import secrets
19
+ import shutil
20
+ import tempfile
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, UploadFile
25
+ from fastapi.responses import ORJSONResponse
26
+
27
+ from ...analytics import AnalyticsService
28
+ from ...config import Settings
29
+ from ...core.service import DocsiferService
30
+ from ...core.url_guard import validate_url
31
+ from ...exceptions import (
32
+ InvalidInputError,
33
+ PayloadTooLargeError,
34
+ UnsupportedFormatError,
35
+ ValidationError,
36
+ )
37
+ from ...safety import ConversionGate, PerIPLimiter, ResourceGuard
38
+ from ..deps import (
39
+ analytics_dep,
40
+ client_ip_dep,
41
+ conversion_gate_dep,
42
+ converter_dep,
43
+ per_ip_limiter_dep,
44
+ resource_guard_dep,
45
+ settings_dep,
46
+ )
47
+ from ..schemas import ConvertResponse, ConvertSettings, HTTPConfig, OpenAIConfig
48
+
49
+ logger = logging.getLogger(__name__)
50
+ router = APIRouter(tags=["v1"])
51
+
52
+ _CHUNK_SIZE = 1024 * 1024 # 1 MB
53
+
54
+
55
+ def _parse_json_form(name: str, raw: str | None, model: type[Any]) -> Any:
56
+ if not raw or not raw.strip():
57
+ return model()
58
+ try:
59
+ return model.model_validate_json(raw)
60
+ except Exception as exc:
61
+ raise ValidationError(
62
+ f"Invalid JSON in '{name}'",
63
+ details={"field": name, "error": str(exc)},
64
+ ) from exc
65
+
66
+
67
+ def _safe_filename(raw: str | None) -> str:
68
+ name = (raw or "").strip()
69
+ safe = Path(name).name if name else ""
70
+ return safe or f"upload-{secrets.token_hex(4)}.bin"
71
+
72
+
73
+ def _check_extension(name: str, allowed: set[str]) -> None:
74
+ suffix = Path(name).suffix.lower()
75
+ if not suffix:
76
+ return # allow extensionless and rely on MIME sniff
77
+ if suffix not in allowed:
78
+ raise UnsupportedFormatError(
79
+ f"Extension '{suffix}' is not allowed",
80
+ details={"allowed": sorted(allowed)},
81
+ )
82
+
83
+
84
+ async def _stream_to_disk(
85
+ upload: UploadFile,
86
+ dst: Path,
87
+ *,
88
+ max_bytes: int,
89
+ ) -> int:
90
+ """Stream the upload to ``dst`` without loading it fully into RAM."""
91
+
92
+ def _copy() -> int:
93
+ total = 0
94
+ with dst.open("wb") as fh:
95
+ while True:
96
+ chunk = upload.file.read(_CHUNK_SIZE)
97
+ if not chunk:
98
+ break
99
+ total += len(chunk)
100
+ if total > max_bytes:
101
+ raise PayloadTooLargeError(
102
+ f"Body exceeds {max_bytes} bytes",
103
+ details={"max_bytes": max_bytes},
104
+ )
105
+ fh.write(chunk)
106
+ return total
107
+
108
+ try:
109
+ return await asyncio.to_thread(_copy)
110
+ finally:
111
+ await upload.close()
112
+
113
+
114
+ @router.post(
115
+ "/convert",
116
+ response_model=ConvertResponse,
117
+ response_class=ORJSONResponse,
118
+ summary="Convert a file or URL into Markdown",
119
+ responses={
120
+ 400: {"description": "Invalid input"},
121
+ 413: {"description": "Payload too large"},
122
+ 415: {"description": "Unsupported format"},
123
+ 422: {"description": "Validation failed"},
124
+ 429: {"description": "Too many requests"},
125
+ 503: {"description": "Service unavailable"},
126
+ },
127
+ )
128
+ async def convert_document(
129
+ background_tasks: BackgroundTasks,
130
+ file: UploadFile | None = File(default=None, description="File to convert"),
131
+ url: str | None = Form(default=None, description="URL to convert"),
132
+ openai: str | None = Form(default=None, description="OpenAI config JSON"),
133
+ http: str | None = Form(default=None, description="HTTP config JSON"),
134
+ settings_form: str | None = Form(default=None, alias="settings"),
135
+ settings: Settings = Depends(settings_dep),
136
+ converter: DocsiferService = Depends(converter_dep),
137
+ analytics: AnalyticsService = Depends(analytics_dep),
138
+ gate: ConversionGate = Depends(conversion_gate_dep),
139
+ per_ip: PerIPLimiter = Depends(per_ip_limiter_dep),
140
+ guard: ResourceGuard = Depends(resource_guard_dep),
141
+ client_ip: str = Depends(client_ip_dep),
142
+ ) -> ConvertResponse:
143
+ if file is None and not (url and url.strip()):
144
+ raise InvalidInputError("Provide either 'file' or 'url'.")
145
+
146
+ openai_cfg = _parse_json_form("openai", openai, OpenAIConfig)
147
+ http_cfg = _parse_json_form("http", http, HTTPConfig)
148
+ convert_cfg = _parse_json_form("settings", settings_form, ConvertSettings)
149
+
150
+ async with per_ip.acquire(client_ip):
151
+ async with gate.acquire():
152
+ if file is not None:
153
+ guard.check(0) # file size unknown until streamed
154
+ _check_extension(file.filename or "", set(settings.allowed_extensions))
155
+
156
+ tmp_root = Path(tempfile.mkdtemp(prefix="docsifer-", dir=settings.tmp_dir))
157
+ try:
158
+ safe_name = _safe_filename(file.filename)
159
+ dst = tmp_root / safe_name
160
+ size = await _stream_to_disk(
161
+ file, dst, max_bytes=settings.max_upload_bytes
162
+ )
163
+ logger.info(
164
+ "Convert file received",
165
+ extra={
166
+ "filename": safe_name,
167
+ "size": size,
168
+ "client_ip": client_ip,
169
+ },
170
+ )
171
+ guard.check(size)
172
+
173
+ result = await asyncio.wait_for(
174
+ converter.convert_file(
175
+ dst,
176
+ openai_config=openai_cfg.to_dict(),
177
+ http_config=http_cfg.to_dict(),
178
+ cleanup_html=convert_cfg.cleanup,
179
+ ),
180
+ timeout=settings.request_timeout_sec,
181
+ )
182
+ finally:
183
+ shutil.rmtree(tmp_root, ignore_errors=True)
184
+ else:
185
+ safe_url = validate_url(
186
+ url or "",
187
+ allowed_schemes=settings.url_allowed_schemes,
188
+ allow_private_networks=settings.url_allow_private_networks,
189
+ )
190
+ logger.info(
191
+ "Convert URL received",
192
+ extra={"url": safe_url, "client_ip": client_ip},
193
+ )
194
+ guard.check(0)
195
+ result = await asyncio.wait_for(
196
+ converter.convert_file(
197
+ safe_url,
198
+ openai_config=openai_cfg.to_dict(),
199
+ http_config=http_cfg.to_dict(),
200
+ cleanup_html=convert_cfg.cleanup,
201
+ ),
202
+ timeout=settings.request_timeout_sec,
203
+ )
204
+
205
+ background_tasks.add_task(_record_access_safe, analytics, result.token_count)
206
+ return ConvertResponse(filename=result.filename, markdown=result.markdown)
207
+
208
+
209
+ async def _record_access_safe(analytics: AnalyticsService, tokens: int) -> None:
210
+ try:
211
+ await analytics.access(tokens)
212
+ except Exception:
213
+ logger.exception("Failed to record analytics access")
docsifer/api/v1/health.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Health probes (liveness + readiness) for orchestration platforms."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends
6
+
7
+ from ...analytics import AnalyticsService
8
+ from ...config import Settings
9
+ from ..deps import analytics_dep, settings_dep
10
+ from ..schemas import HealthResponse
11
+
12
+ router = APIRouter(tags=["health"])
13
+
14
+
15
+ @router.get(
16
+ "/healthz",
17
+ response_model=HealthResponse,
18
+ summary="Liveness probe",
19
+ description="Always returns 200 if the process is responsive.",
20
+ )
21
+ async def healthz(settings: Settings = Depends(settings_dep)) -> HealthResponse:
22
+ return HealthResponse(status="ok", version=settings.app_version)
23
+
24
+
25
+ @router.get(
26
+ "/readyz",
27
+ response_model=HealthResponse,
28
+ summary="Readiness probe",
29
+ description="Returns 200 only when the analytics backend is reachable.",
30
+ )
31
+ async def readyz(
32
+ analytics: AnalyticsService = Depends(analytics_dep),
33
+ settings: Settings = Depends(settings_dep),
34
+ ) -> HealthResponse:
35
+ healthy = await analytics.ping() if settings.analytics_persistent else True
36
+ if not healthy:
37
+ return HealthResponse(
38
+ status="degraded",
39
+ version=settings.app_version,
40
+ details={"analytics": "unreachable"},
41
+ )
42
+ return HealthResponse(status="ready", version=settings.app_version)
docsifer/api/v1/stats.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Usage statistics endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends
6
+
7
+ from ...analytics import AnalyticsService
8
+ from ..deps import analytics_dep
9
+ from ..schemas import StatsResponse
10
+
11
+ router = APIRouter(tags=["v1"])
12
+
13
+
14
+ @router.get(
15
+ "/stats",
16
+ response_model=StatsResponse,
17
+ summary="Get usage statistics",
18
+ )
19
+ async def get_stats(
20
+ analytics: AnalyticsService = Depends(analytics_dep),
21
+ ) -> StatsResponse:
22
+ snap = await analytics.stats()
23
+ return StatsResponse(
24
+ access=snap.get("access", {}),
25
+ tokens=snap.get("tokens", {}),
26
+ healthy=bool(snap.get("healthy", True)),
27
+ )
docsifer/config.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Application settings loaded from environment variables.
2
+
3
+ All configuration is centralized here to avoid scattered hardcoded values.
4
+ Environment variables are prefixed with ``DOCSIFER_`` (see ``.env.example``).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import tempfile
10
+ from functools import lru_cache
11
+ from pathlib import Path
12
+ from typing import Literal
13
+
14
+ from pydantic import Field, field_validator
15
+ from pydantic_settings import BaseSettings, SettingsConfigDict
16
+
17
+
18
+ class Settings(BaseSettings):
19
+ """Centralized, env-driven application settings."""
20
+
21
+ model_config = SettingsConfigDict(
22
+ env_prefix="DOCSIFER_",
23
+ env_file=".env",
24
+ env_file_encoding="utf-8",
25
+ extra="ignore",
26
+ case_sensitive=False,
27
+ )
28
+
29
+ # ---------------------------------------------------------------------
30
+ # General
31
+ # ---------------------------------------------------------------------
32
+ app_name: str = "Docsifer"
33
+ app_version: str = "1.1.0"
34
+ environment: Literal["development", "staging", "production"] = "production"
35
+ log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
36
+ log_json: bool = True
37
+
38
+ # ---------------------------------------------------------------------
39
+ # HTTP / API
40
+ # ---------------------------------------------------------------------
41
+ cors_origins: list[str] = Field(default_factory=lambda: ["*"])
42
+ cors_allow_credentials: bool = False # safe with origins=["*"]
43
+ request_timeout_sec: int = 55 # < HF Spaces 60s timeout
44
+ max_upload_bytes: int = 10 * 1024 * 1024 # 10 MB free-tier default
45
+ gzip_min_size: int = 1024
46
+ enable_security_headers: bool = True
47
+
48
+ # ---------------------------------------------------------------------
49
+ # Concurrency / resources
50
+ # ---------------------------------------------------------------------
51
+ max_concurrent_conversions: int = 2 # tuned for 2 vCPU
52
+ max_queue_depth: int = 10
53
+ max_per_ip_concurrent: int = 1
54
+ worker_pool_size: int = 4 # ThreadPoolExecutor for sync work
55
+ min_free_memory_mb: int = 512
56
+ min_free_disk_mb: int = 256
57
+ memory_watchdog_pct: float = 90.0
58
+ memory_watchdog_interval_sec: int = 30
59
+ enable_memory_watchdog: bool = False # disabled by default in dev
60
+ disk_cleanup_interval_sec: int = 600
61
+ disk_cleanup_ttl_sec: int = 3600
62
+
63
+ # ---------------------------------------------------------------------
64
+ # Conversion
65
+ # ---------------------------------------------------------------------
66
+ token_model: str = "gpt-4o"
67
+ default_openai_base_url: str = "https://api.openai.com/v1"
68
+ default_openai_model: str = "gpt-4o-mini"
69
+ openai_request_timeout_sec: float = 60.0
70
+ openai_connect_timeout_sec: float = 10.0
71
+ openai_max_retries: int = 2
72
+
73
+ # LLM client cache
74
+ llm_cache_max_size: int = 16
75
+ llm_cache_ttl_sec: int = 600
76
+
77
+ # Allowed file extensions (lower-case, with leading dot)
78
+ allowed_extensions: list[str] = Field(
79
+ default_factory=lambda: [
80
+ ".html",
81
+ ".htm",
82
+ ".zip",
83
+ ".jpg",
84
+ ".jpeg",
85
+ ".png",
86
+ ".gif",
87
+ ".webp",
88
+ ".csv",
89
+ ".tsv",
90
+ ".ipynb",
91
+ ".msg",
92
+ ".xml",
93
+ ".docx",
94
+ ".doc",
95
+ ".json",
96
+ ".pptx",
97
+ ".ppt",
98
+ ".xls",
99
+ ".xlsx",
100
+ ".pdf",
101
+ ".mp3",
102
+ ".wav",
103
+ ".m4a",
104
+ ".txt",
105
+ ".md",
106
+ ".rtf",
107
+ ]
108
+ )
109
+
110
+ # ---------------------------------------------------------------------
111
+ # SSRF / URL fetch guard
112
+ # ---------------------------------------------------------------------
113
+ url_allow_private_networks: bool = False
114
+ url_allowed_schemes: list[str] = Field(default_factory=lambda: ["http", "https"])
115
+
116
+ # ---------------------------------------------------------------------
117
+ # Analytics / Redis
118
+ #
119
+ # ``redis_url`` is empty by default so fresh deployments (HF Spaces, local
120
+ # dev) start in in-memory analytics mode out of the box. Set
121
+ # ``DOCSIFER_REDIS_URL`` (and ``DOCSIFER_REDIS_TOKEN`` for Upstash) to
122
+ # opt into persistent analytics.
123
+ # ---------------------------------------------------------------------
124
+ redis_url: str = ""
125
+ redis_token: str | None = None
126
+ analytics_enabled: bool = True
127
+ analytics_sync_interval_sec: int = 1800 # 30 min
128
+ analytics_max_retries: int = 5
129
+ analytics_label: str = "docsifer"
130
+
131
+ @property
132
+ def analytics_persistent(self) -> bool:
133
+ """True when a real (non-empty, non-localhost) Redis URL is configured."""
134
+ if not self.analytics_enabled:
135
+ return False
136
+ url = (self.redis_url or "").strip()
137
+ if not url:
138
+ return False
139
+ return True
140
+
141
+ # ---------------------------------------------------------------------
142
+ # Quotas (anonymous, BYOK, authenticated)
143
+ # ---------------------------------------------------------------------
144
+ quota_enabled: bool = False # enable when slowapi/redis is configured
145
+ quota_anon_rph: int = 10
146
+ quota_anon_rpd: int = 50
147
+ quota_byok_rph: int = 60
148
+ quota_byok_rpd: int = 500
149
+ quota_auth_keys: list[str] = Field(default_factory=list)
150
+
151
+ # ---------------------------------------------------------------------
152
+ # Observability
153
+ # ---------------------------------------------------------------------
154
+ enable_prometheus: bool = False
155
+ sentry_dsn: str | None = None
156
+
157
+ # ---------------------------------------------------------------------
158
+ # Filesystem
159
+ # ---------------------------------------------------------------------
160
+ tmp_dir: Path = Field(default_factory=lambda: Path(tempfile.gettempdir()))
161
+
162
+ # ---------------------------------------------------------------------
163
+ # Validators
164
+ # ---------------------------------------------------------------------
165
+ @field_validator("allowed_extensions", mode="before")
166
+ @classmethod
167
+ def _normalize_exts(cls, v: object) -> list[str]:
168
+ if isinstance(v, str):
169
+ v = [item.strip() for item in v.split(",") if item.strip()]
170
+ if not isinstance(v, list):
171
+ raise TypeError("allowed_extensions must be a list or comma-separated string")
172
+ return [e.lower() if e.startswith(".") else f".{e.lower()}" for e in v]
173
+
174
+ @field_validator("cors_origins", "url_allowed_schemes", "quota_auth_keys", mode="before")
175
+ @classmethod
176
+ def _split_csv(cls, v: object) -> list[str]:
177
+ if isinstance(v, str):
178
+ return [item.strip() for item in v.split(",") if item.strip()]
179
+ return list(v) if v else []
180
+
181
+ # ---------------------------------------------------------------------
182
+ # Convenience properties
183
+ # ---------------------------------------------------------------------
184
+ @property
185
+ def cors_allow_credentials_safe(self) -> bool:
186
+ """``allow_credentials`` is invalid together with ``allow_origins=['*']``."""
187
+ if "*" in self.cors_origins:
188
+ return False
189
+ return self.cors_allow_credentials
190
+
191
+ @property
192
+ def is_production(self) -> bool:
193
+ return self.environment == "production"
194
+
195
+
196
+ @lru_cache(maxsize=1)
197
+ def get_settings() -> Settings:
198
+ """Return a process-wide singleton ``Settings`` instance."""
199
+ return Settings()
docsifer/core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Core domain layer (no FastAPI / Gradio dependencies)."""
docsifer/core/html_cleaner.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HTML cleanup utilities.
2
+
3
+ Uses ``selectolax`` (≈2-3× faster than lxml/pyquery) when available with a
4
+ graceful fallback to pure regex for environments where selectolax is missing.
5
+ The cleaner removes scripts, styles, hidden nodes and elements relying on
6
+ ``display:none`` / ``visibility:hidden`` / ``aria-hidden``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import re
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ try: # pragma: no cover - import-time only
17
+ from selectolax.parser import HTMLParser # type: ignore
18
+
19
+ _HAS_SELECTOLAX = True
20
+ except Exception: # pragma: no cover
21
+ HTMLParser = None # type: ignore[assignment]
22
+ _HAS_SELECTOLAX = False
23
+
24
+
25
+ _REMOVE_SELECTORS: tuple[str, ...] = (
26
+ "style",
27
+ "script",
28
+ "noscript",
29
+ "[hidden]",
30
+ '[style*="display:none"]',
31
+ '[style*="display: none"]',
32
+ '[style*="visibility:hidden"]',
33
+ '[style*="visibility: hidden"]',
34
+ '[aria-hidden="true"]',
35
+ )
36
+
37
+ _FALLBACK_PATTERNS: tuple[re.Pattern[str], ...] = (
38
+ re.compile(r"<style\b[^>]*>.*?</style>", re.IGNORECASE | re.DOTALL),
39
+ re.compile(r"<script\b[^>]*>.*?</script>", re.IGNORECASE | re.DOTALL),
40
+ re.compile(r"<noscript\b[^>]*>.*?</noscript>", re.IGNORECASE | re.DOTALL),
41
+ )
42
+
43
+
44
+ def clean_html(html: str) -> str:
45
+ """Return ``html`` with hidden / inert nodes removed.
46
+
47
+ The function is best-effort: any parser failure logs at DEBUG and returns
48
+ the original input unchanged so downstream conversion can still proceed.
49
+ """
50
+ if not html:
51
+ return html
52
+
53
+ if _HAS_SELECTOLAX:
54
+ try:
55
+ tree = HTMLParser(html)
56
+ for selector in _REMOVE_SELECTORS:
57
+ for node in tree.css(selector):
58
+ node.decompose()
59
+ return tree.html or ""
60
+ except Exception as exc:
61
+ logger.debug("selectolax cleanup failed: %s", exc)
62
+
63
+ cleaned = html
64
+ for pattern in _FALLBACK_PATTERNS:
65
+ cleaned = pattern.sub("", cleaned)
66
+ return cleaned
67
+
68
+
69
+ def clean_html_bytes(data: bytes, encoding: str = "utf-8") -> bytes:
70
+ """Convenience wrapper for byte input/output (used to avoid extra decodes)."""
71
+ text = data.decode(encoding, errors="ignore")
72
+ return clean_html(text).encode(encoding, errors="ignore")
docsifer/core/llm_registry.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TTL-cached registry of MarkItDown instances configured with an LLM client.
2
+
3
+ Creating an ``OpenAI`` client (and the wrapping ``MarkItDown``) is expensive
4
+ because each ``OpenAI`` instance owns a private ``httpx.Client`` connection
5
+ pool and triggers a TLS handshake on first use. We cache one instance per
6
+ ``(api_key_hash, base_url, model)`` tuple so repeated requests reuse the same
7
+ TCP/TLS session.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import logging
14
+ import threading
15
+ from dataclasses import dataclass
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ import httpx
19
+ from cachetools import TTLCache
20
+ from markitdown import MarkItDown
21
+ from openai import OpenAI
22
+
23
+ if TYPE_CHECKING:
24
+ pass
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class LLMConfig:
31
+ api_key: str
32
+ base_url: str
33
+ model: str
34
+
35
+ @property
36
+ def cache_key(self) -> str:
37
+ digest = hashlib.sha256(self.api_key.encode("utf-8")).hexdigest()[:16]
38
+ return f"{digest}|{self.base_url}|{self.model}"
39
+
40
+ @classmethod
41
+ def from_dict(
42
+ cls,
43
+ data: dict[str, Any] | None,
44
+ *,
45
+ default_base_url: str,
46
+ default_model: str,
47
+ ) -> "LLMConfig | None":
48
+ if not data:
49
+ return None
50
+ api_key = (data.get("api_key") or "").strip()
51
+ if not api_key:
52
+ return None
53
+ base_url = (data.get("base_url") or default_base_url).strip().rstrip("/")
54
+ model = (data.get("model") or default_model).strip()
55
+ return cls(api_key=api_key, base_url=base_url, model=model)
56
+
57
+
58
+ class LLMRegistry:
59
+ """Thread-safe TTL cache of LLM-enabled :class:`MarkItDown` instances."""
60
+
61
+ def __init__(
62
+ self,
63
+ *,
64
+ max_size: int = 16,
65
+ ttl: int = 600,
66
+ request_timeout: float = 60.0,
67
+ connect_timeout: float = 10.0,
68
+ max_retries: int = 2,
69
+ ) -> None:
70
+ self._cache: TTLCache[str, MarkItDown] = TTLCache(maxsize=max_size, ttl=ttl)
71
+ self._lock = threading.Lock()
72
+ self._request_timeout = request_timeout
73
+ self._connect_timeout = connect_timeout
74
+ self._max_retries = max_retries
75
+
76
+ def get(self, config: LLMConfig) -> MarkItDown:
77
+ """Return a cached or freshly built :class:`MarkItDown` for ``config``."""
78
+ key = config.cache_key
79
+ with self._lock:
80
+ instance = self._cache.get(key)
81
+ if instance is not None:
82
+ return instance
83
+ instance = self._build(config)
84
+ self._cache[key] = instance
85
+ logger.info(
86
+ "LLM client created",
87
+ extra={"base_url": config.base_url, "model": config.model},
88
+ )
89
+ return instance
90
+
91
+ def _build(self, config: LLMConfig) -> MarkItDown:
92
+ client = OpenAI(
93
+ api_key=config.api_key,
94
+ base_url=config.base_url,
95
+ timeout=httpx.Timeout(
96
+ self._request_timeout, connect=self._connect_timeout
97
+ ),
98
+ max_retries=self._max_retries,
99
+ )
100
+ return MarkItDown(llm_client=client, llm_model=config.model)
101
+
102
+ def clear(self) -> None:
103
+ with self._lock:
104
+ self._cache.clear()
docsifer/core/mime.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MIME detection helpers.
2
+
3
+ Trust the file extension when present and recognized; fall back to libmagic
4
+ sniffing of just the first 4 KB to avoid reading entire large files into RAM.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import mimetypes
11
+ from pathlib import Path
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Lazy-import the optional ``magic`` dependency so the application keeps
16
+ # working when libmagic is unavailable.
17
+ try: # pragma: no cover - environment dependent
18
+ import magic as _magic # type: ignore
19
+
20
+ _HAS_MAGIC = True
21
+ except Exception as exc: # pragma: no cover
22
+ logger.warning("python-magic unavailable, falling back to extensions only: %s", exc)
23
+ _magic = None # type: ignore[assignment]
24
+ _HAS_MAGIC = False
25
+
26
+ _SNIFF_BYTES = 4096
27
+
28
+
29
+ def detect_mime(path: Path, *, sniff_bytes: int = _SNIFF_BYTES) -> str | None:
30
+ """Return the MIME type of ``path`` or ``None`` when unknown.
31
+
32
+ Reads at most ``sniff_bytes`` from the start of the file.
33
+ """
34
+ if _HAS_MAGIC:
35
+ try:
36
+ with path.open("rb") as fh:
37
+ head = fh.read(sniff_bytes)
38
+ return _magic.from_buffer(head, mime=True) or None # type: ignore[union-attr]
39
+ except Exception as exc:
40
+ logger.debug("magic sniff failed for %s: %s", path, exc)
41
+ # Fallback: derive from extension
42
+ guess, _ = mimetypes.guess_type(str(path))
43
+ return guess
44
+
45
+
46
+ def guess_extension(mime: str | None) -> str | None:
47
+ """Return a canonical ``.ext`` string for ``mime`` (or ``None``)."""
48
+ if not mime:
49
+ return None
50
+ return mimetypes.guess_extension(mime)
51
+
52
+
53
+ def normalize_extension(
54
+ path: Path,
55
+ *,
56
+ known_extensions: set[str] | None = None,
57
+ ) -> Path:
58
+ """Ensure ``path`` has a sensible extension.
59
+
60
+ If the current extension is in ``known_extensions``, it is trusted.
61
+ Otherwise we sniff the MIME type and rename the file in place using
62
+ :func:`os.rename` (atomic, zero-copy).
63
+ """
64
+ suffix = path.suffix.lower()
65
+ if known_extensions and suffix in known_extensions:
66
+ return path
67
+
68
+ mime = detect_mime(path)
69
+ ext = guess_extension(mime)
70
+ if not ext or ext.lower() == suffix:
71
+ return path
72
+
73
+ new_path = path.with_suffix(ext)
74
+ try:
75
+ path.rename(new_path)
76
+ return new_path
77
+ except OSError as exc:
78
+ logger.warning("Could not rename %s -> %s: %s", path, new_path, exc)
79
+ return path
docsifer/core/service.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure conversion service.
2
+
3
+ The service is fully framework-agnostic so it can be invoked directly from
4
+ the FastAPI route handlers, the Gradio UI or background workers without
5
+ issuing self-HTTP loopback calls.
6
+
7
+ Key design points:
8
+ - Streaming MarkItDown conversion via :class:`io.BytesIO` whenever possible
9
+ (avoids the read → write → reread cycle of the previous implementation).
10
+ - Cached LLM clients (see :class:`docsifer.core.llm_registry.LLMRegistry`).
11
+ - Bounded :class:`concurrent.futures.ThreadPoolExecutor` for sync work so
12
+ default executor saturation can never block unrelated coroutines.
13
+ - HTTP cookies are forwarded to MarkItDown via a dedicated
14
+ :class:`requests.Session`, fixing the previous broken cookie injection.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import contextlib
21
+ import io
22
+ import logging
23
+ from concurrent.futures import ThreadPoolExecutor
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ import requests
29
+ from markitdown import MarkItDown
30
+
31
+ from .html_cleaner import clean_html_bytes
32
+ from .llm_registry import LLMConfig, LLMRegistry
33
+ from .mime import normalize_extension
34
+ from .tokenizer import TiktokenCounter
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ _HTML_EXTS = frozenset({".html", ".htm"})
39
+
40
+
41
+ @dataclass(slots=True)
42
+ class ConvertResult:
43
+ filename: str
44
+ markdown: str
45
+ token_count: int
46
+
47
+ def to_dict(self) -> dict[str, Any]:
48
+ return {"filename": self.filename, "markdown": self.markdown}
49
+
50
+
51
+ class DocsiferService:
52
+ """Convert local files or remote URLs into Markdown."""
53
+
54
+ def __init__(
55
+ self,
56
+ *,
57
+ token_model: str = "gpt-4o",
58
+ default_openai_base_url: str = "https://api.openai.com/v1",
59
+ default_openai_model: str = "gpt-4o-mini",
60
+ worker_pool_size: int = 4,
61
+ llm_cache_max_size: int = 16,
62
+ llm_cache_ttl: int = 600,
63
+ openai_request_timeout: float = 60.0,
64
+ openai_connect_timeout: float = 10.0,
65
+ openai_max_retries: int = 2,
66
+ known_extensions: set[str] | None = None,
67
+ ) -> None:
68
+ self._basic_md = MarkItDown()
69
+ self._token_counter = TiktokenCounter(token_model)
70
+ self._llm_registry = LLMRegistry(
71
+ max_size=llm_cache_max_size,
72
+ ttl=llm_cache_ttl,
73
+ request_timeout=openai_request_timeout,
74
+ connect_timeout=openai_connect_timeout,
75
+ max_retries=openai_max_retries,
76
+ )
77
+ self._executor = ThreadPoolExecutor(
78
+ max_workers=worker_pool_size,
79
+ thread_name_prefix="docsifer-worker",
80
+ )
81
+ self._default_base_url = default_openai_base_url
82
+ self._default_model = default_openai_model
83
+ self._known_extensions = known_extensions or set()
84
+
85
+ # ------------------------------------------------------------------
86
+ # Public API
87
+ # ------------------------------------------------------------------
88
+ async def convert_file(
89
+ self,
90
+ source: str | Path,
91
+ *,
92
+ openai_config: dict[str, Any] | None = None,
93
+ http_config: dict[str, Any] | None = None,
94
+ cleanup_html: bool = True,
95
+ ) -> ConvertResult:
96
+ """Convert a local file path or HTTP(S) URL into Markdown."""
97
+ loop = asyncio.get_running_loop()
98
+ return await loop.run_in_executor(
99
+ self._executor,
100
+ self._convert_sync,
101
+ source,
102
+ openai_config,
103
+ http_config,
104
+ cleanup_html,
105
+ )
106
+
107
+ async def shutdown(self) -> None:
108
+ """Stop the worker pool gracefully (called from app lifespan)."""
109
+ self._llm_registry.clear()
110
+ await asyncio.get_running_loop().run_in_executor(
111
+ None, self._executor.shutdown, True
112
+ )
113
+
114
+ # ------------------------------------------------------------------
115
+ # Internal
116
+ # ------------------------------------------------------------------
117
+ def _convert_sync(
118
+ self,
119
+ source: str | Path,
120
+ openai_config: dict[str, Any] | None,
121
+ http_config: dict[str, Any] | None,
122
+ cleanup_html: bool,
123
+ ) -> ConvertResult:
124
+ is_url = isinstance(source, str) and source.lower().startswith(("http://", "https://"))
125
+
126
+ md_converter = self._select_converter(openai_config)
127
+ session = self._build_session(http_config) if http_config else None
128
+
129
+ if is_url:
130
+ return self._convert_url(str(source), md_converter, session)
131
+ return self._convert_path(Path(source), md_converter, cleanup_html)
132
+
133
+ # -- LLM / converter selection ------------------------------------------------
134
+ def _select_converter(self, openai_config: dict[str, Any] | None) -> MarkItDown:
135
+ cfg = LLMConfig.from_dict(
136
+ openai_config,
137
+ default_base_url=self._default_base_url,
138
+ default_model=self._default_model,
139
+ )
140
+ if cfg is None:
141
+ return self._basic_md
142
+ try:
143
+ return self._llm_registry.get(cfg)
144
+ except Exception as exc: # pragma: no cover - defensive
145
+ logger.warning("Falling back to basic MarkItDown (LLM init failed): %s", exc)
146
+ return self._basic_md
147
+
148
+ @staticmethod
149
+ def _build_session(http_config: dict[str, Any]) -> requests.Session | None:
150
+ session = requests.Session()
151
+ cookies = http_config.get("cookies")
152
+ if isinstance(cookies, dict) and cookies:
153
+ session.cookies.update({str(k): str(v) for k, v in cookies.items()})
154
+ headers = http_config.get("headers")
155
+ if isinstance(headers, dict) and headers:
156
+ session.headers.update({str(k): str(v) for k, v in headers.items()})
157
+ return session
158
+
159
+ # -- URL conversion -----------------------------------------------------------
160
+ def _convert_url(
161
+ self,
162
+ url: str,
163
+ md_converter: MarkItDown,
164
+ session: requests.Session | None,
165
+ ) -> ConvertResult:
166
+ try:
167
+ if session is not None and hasattr(md_converter, "convert_url"):
168
+ # markitdown >= 0.0.x exposes convert_url with a session kwarg
169
+ result_obj = md_converter.convert_url(url, requests_session=session) # type: ignore[arg-type]
170
+ else:
171
+ result_obj = md_converter.convert(url)
172
+ except Exception as exc:
173
+ logger.error("URL conversion failed: %s", exc)
174
+ raise RuntimeError(f"Conversion failed for URL '{url}': {exc}") from exc
175
+
176
+ text = getattr(result_obj, "text_content", "") or ""
177
+ return ConvertResult(
178
+ filename=self._derive_url_filename(url),
179
+ markdown=text,
180
+ token_count=self._token_counter.count(text),
181
+ )
182
+
183
+ @staticmethod
184
+ def _derive_url_filename(url: str) -> str:
185
+ from urllib.parse import urlparse
186
+
187
+ parsed = urlparse(url)
188
+ last = (parsed.path or "/").rsplit("/", 1)[-1] or "index.html"
189
+ if "." not in last:
190
+ last = f"{last}.html"
191
+ return last
192
+
193
+ # -- Local file conversion ----------------------------------------------------
194
+ def _convert_path(
195
+ self,
196
+ path: Path,
197
+ md_converter: MarkItDown,
198
+ cleanup_html: bool,
199
+ ) -> ConvertResult:
200
+ if not path.exists():
201
+ raise FileNotFoundError(f"File not found: {path}")
202
+
203
+ normalized = normalize_extension(path, known_extensions=self._known_extensions)
204
+ ext = normalized.suffix.lower()
205
+
206
+ if cleanup_html and ext in _HTML_EXTS:
207
+ return self._convert_html_streaming(normalized, md_converter, ext)
208
+
209
+ try:
210
+ result_obj = md_converter.convert(str(normalized))
211
+ except Exception as exc:
212
+ logger.error("File conversion failed: %s", exc)
213
+ raise RuntimeError(f"Conversion failed for '{normalized.name}': {exc}") from exc
214
+
215
+ text = getattr(result_obj, "text_content", "") or ""
216
+ return ConvertResult(
217
+ filename=normalized.name,
218
+ markdown=text,
219
+ token_count=self._token_counter.count(text),
220
+ )
221
+
222
+ def _convert_html_streaming(
223
+ self,
224
+ path: Path,
225
+ md_converter: MarkItDown,
226
+ ext: str,
227
+ ) -> ConvertResult:
228
+ """Read → clean → feed MarkItDown without writing back to disk."""
229
+ try:
230
+ data = path.read_bytes()
231
+ cleaned = clean_html_bytes(data)
232
+ except Exception as exc:
233
+ logger.warning("HTML cleanup failed (%s); falling back to raw conversion", exc)
234
+ cleaned = path.read_bytes()
235
+
236
+ stream = io.BytesIO(cleaned)
237
+ try:
238
+ if hasattr(md_converter, "convert_stream"):
239
+ result_obj = md_converter.convert_stream(stream, file_extension=ext)
240
+ else: # pragma: no cover - older markitdown
241
+ # Fallback: write cleaned bytes to a sibling temp file
242
+ with contextlib.suppress(Exception):
243
+ path.write_bytes(cleaned)
244
+ result_obj = md_converter.convert(str(path))
245
+ except Exception as exc:
246
+ logger.error("HTML conversion failed: %s", exc)
247
+ raise RuntimeError(f"Conversion failed for '{path.name}': {exc}") from exc
248
+
249
+ text = getattr(result_obj, "text_content", "") or ""
250
+ return ConvertResult(
251
+ filename=path.name,
252
+ markdown=text,
253
+ token_count=self._token_counter.count(text),
254
+ )
docsifer/core/tokenizer.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Token counting backed by ``tiktoken`` with a robust fallback chain."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Protocol
7
+
8
+ import tiktoken
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class TokenCounter(Protocol):
14
+ def count(self, text: str) -> int: ...
15
+
16
+
17
+ class TiktokenCounter:
18
+ """Token counter backed by ``tiktoken``.
19
+
20
+ Falls back through ``cl100k_base`` and finally a whitespace heuristic so
21
+ that token counting never raises in production paths.
22
+ """
23
+
24
+ def __init__(self, model_name: str = "gpt-4o") -> None:
25
+ self._encoder = self._load_encoder(model_name)
26
+
27
+ @staticmethod
28
+ def _load_encoder(model_name: str) -> tiktoken.Encoding | None:
29
+ try:
30
+ return tiktoken.encoding_for_model(model_name)
31
+ except Exception as exc: # pragma: no cover - defensive
32
+ logger.warning(
33
+ "tiktoken model '%s' unavailable (%s); using cl100k_base",
34
+ model_name,
35
+ exc,
36
+ )
37
+ try:
38
+ return tiktoken.get_encoding("cl100k_base")
39
+ except Exception as exc: # pragma: no cover - defensive
40
+ logger.error("tiktoken unavailable: %s; using whitespace heuristic", exc)
41
+ return None
42
+
43
+ def count(self, text: str) -> int:
44
+ if not text:
45
+ return 0
46
+ if self._encoder is None:
47
+ return len(text.split())
48
+ try:
49
+ return len(self._encoder.encode(text))
50
+ except Exception as exc:
51
+ logger.warning("Token encoding failed (%s); using whitespace heuristic", exc)
52
+ return len(text.split())
docsifer/core/url_guard.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SSRF protection for user-supplied URLs.
2
+
3
+ Validates scheme, blocks loopback / link-local / private / multicast /
4
+ reserved networks unless explicitly allowed via configuration.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ipaddress
10
+ import logging
11
+ import socket
12
+ from urllib.parse import urlparse
13
+
14
+ from ..exceptions import InvalidInputError
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def _is_blocked_address(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
20
+ return (
21
+ ip.is_loopback
22
+ or ip.is_link_local
23
+ or ip.is_multicast
24
+ or ip.is_reserved
25
+ or ip.is_private
26
+ or ip.is_unspecified
27
+ )
28
+
29
+
30
+ def validate_url(
31
+ url: str,
32
+ *,
33
+ allowed_schemes: list[str] | None = None,
34
+ allow_private_networks: bool = False,
35
+ ) -> str:
36
+ """Return a sanitized URL string or raise :class:`InvalidInputError`.
37
+
38
+ When ``allow_private_networks`` is ``False`` (default) we resolve the
39
+ hostname and reject any address pointing at private/loopback ranges, which
40
+ blocks the typical SSRF vectors (e.g. AWS metadata at 169.254.169.254).
41
+ """
42
+ if not url or not url.strip():
43
+ raise InvalidInputError("URL must not be empty")
44
+
45
+ parsed = urlparse(url.strip())
46
+ scheme = (parsed.scheme or "").lower()
47
+ schemes = [s.lower() for s in (allowed_schemes or ["http", "https"])]
48
+ if scheme not in schemes:
49
+ raise InvalidInputError(
50
+ f"URL scheme '{scheme or '<none>'}' is not allowed",
51
+ details={"allowed_schemes": schemes},
52
+ )
53
+ if not parsed.hostname:
54
+ raise InvalidInputError("URL must include a hostname")
55
+
56
+ if allow_private_networks:
57
+ return parsed.geturl()
58
+
59
+ host = parsed.hostname
60
+ try:
61
+ infos = socket.getaddrinfo(host, None)
62
+ except socket.gaierror as exc:
63
+ raise InvalidInputError(
64
+ f"Could not resolve host: {host}", details={"error": str(exc)}
65
+ ) from exc
66
+
67
+ for info in infos:
68
+ sockaddr = info[4]
69
+ ip_str = sockaddr[0]
70
+ try:
71
+ ip = ipaddress.ip_address(ip_str)
72
+ except ValueError:
73
+ continue
74
+ if _is_blocked_address(ip):
75
+ logger.warning("Blocked SSRF target host=%s ip=%s", host, ip_str)
76
+ raise InvalidInputError(
77
+ "URL points to a non-public address",
78
+ details={"host": host},
79
+ )
80
+
81
+ return parsed.geturl()
docsifer/exceptions.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Domain-level exception hierarchy mapped to HTTP status codes.
2
+
3
+ Endpoints raise ``DocsiferError`` subclasses; the global exception handler
4
+ converts them into JSON responses with sanitized messages so internal stack
5
+ traces never leak to clients.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+
13
+ class DocsiferError(Exception):
14
+ """Base class for all application-level errors."""
15
+
16
+ status_code: int = 500
17
+ public_message: str = "Internal server error"
18
+
19
+ def __init__(
20
+ self,
21
+ message: str | None = None,
22
+ *,
23
+ public_message: str | None = None,
24
+ details: dict[str, Any] | None = None,
25
+ ) -> None:
26
+ super().__init__(message or self.public_message)
27
+ self.details = details or {}
28
+ if public_message is not None:
29
+ self.public_message = public_message
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # 4xx — client errors
34
+ # ---------------------------------------------------------------------------
35
+ class InvalidInputError(DocsiferError):
36
+ status_code = 400
37
+ public_message = "Invalid input"
38
+
39
+
40
+ class UnauthorizedError(DocsiferError):
41
+ status_code = 401
42
+ public_message = "Unauthorized"
43
+
44
+
45
+ class ForbiddenError(DocsiferError):
46
+ status_code = 403
47
+ public_message = "Forbidden"
48
+
49
+
50
+ class NotFoundError(DocsiferError):
51
+ status_code = 404
52
+ public_message = "Resource not found"
53
+
54
+
55
+ class PayloadTooLargeError(DocsiferError):
56
+ status_code = 413
57
+ public_message = "Payload too large"
58
+
59
+
60
+ class UnsupportedFormatError(DocsiferError):
61
+ status_code = 415
62
+ public_message = "Unsupported file format"
63
+
64
+
65
+ class ValidationError(DocsiferError):
66
+ status_code = 422
67
+ public_message = "Validation failed"
68
+
69
+
70
+ class TooManyRequestsError(DocsiferError):
71
+ status_code = 429
72
+ public_message = "Too many requests"
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # 5xx — server errors
77
+ # ---------------------------------------------------------------------------
78
+ class ConversionFailedError(DocsiferError):
79
+ status_code = 500
80
+ public_message = "Conversion failed"
81
+
82
+
83
+ class UpstreamLLMError(DocsiferError):
84
+ status_code = 502
85
+ public_message = "Upstream LLM error"
86
+
87
+
88
+ class ServiceUnavailableError(DocsiferError):
89
+ status_code = 503
90
+ public_message = "Service unavailable"
91
+
92
+
93
+ class ResourceExhaustedError(ServiceUnavailableError):
94
+ public_message = "Server resources are exhausted, please retry later"
95
+
96
+
97
+ class CircuitOpenError(ServiceUnavailableError):
98
+ public_message = "Upstream temporarily unavailable"
99
+
100
+
101
+ class QueueFullError(ServiceUnavailableError):
102
+ public_message = "Server is busy, please retry later"
103
+
104
+
105
+ class GatewayTimeoutError(DocsiferError):
106
+ status_code = 504
107
+ public_message = "Upstream timeout"
docsifer/logging_config.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Logging setup with optional structured JSON output and request-id binding.
2
+
3
+ Call :func:`configure_logging` exactly once at application start (inside the
4
+ FastAPI lifespan).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import logging.config
12
+ import sys
13
+ from contextvars import ContextVar
14
+ from typing import Any
15
+
16
+ #: Context variable carrying the current request id for the active task.
17
+ request_id_var: ContextVar[str | None] = ContextVar("request_id", default=None)
18
+
19
+
20
+ class RequestIdFilter(logging.Filter):
21
+ """Inject the current request id (if any) into every log record."""
22
+
23
+ def filter(self, record: logging.LogRecord) -> bool:
24
+ record.request_id = request_id_var.get() or "-"
25
+ return True
26
+
27
+
28
+ class JsonFormatter(logging.Formatter):
29
+ """Minimal JSON formatter producing a single line per record."""
30
+
31
+ _RESERVED = {
32
+ "args",
33
+ "asctime",
34
+ "created",
35
+ "exc_info",
36
+ "exc_text",
37
+ "filename",
38
+ "funcName",
39
+ "levelname",
40
+ "levelno",
41
+ "lineno",
42
+ "module",
43
+ "msecs",
44
+ "message",
45
+ "msg",
46
+ "name",
47
+ "pathname",
48
+ "process",
49
+ "processName",
50
+ "relativeCreated",
51
+ "stack_info",
52
+ "thread",
53
+ "threadName",
54
+ }
55
+
56
+ def format(self, record: logging.LogRecord) -> str:
57
+ payload: dict[str, Any] = {
58
+ "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
59
+ "level": record.levelname,
60
+ "logger": record.name,
61
+ "msg": record.getMessage(),
62
+ "request_id": getattr(record, "request_id", "-"),
63
+ }
64
+ if record.exc_info:
65
+ payload["exc"] = self.formatException(record.exc_info)
66
+ # Allow callers to attach extra fields via ``logger.info("...", extra={...})``.
67
+ for key, value in record.__dict__.items():
68
+ if key in self._RESERVED or key.startswith("_") or key == "request_id":
69
+ continue
70
+ try:
71
+ json.dumps(value)
72
+ payload[key] = value
73
+ except (TypeError, ValueError):
74
+ payload[key] = repr(value)
75
+ return json.dumps(payload, ensure_ascii=False, default=str)
76
+
77
+
78
+ def configure_logging(level: str = "INFO", json_output: bool = True) -> None:
79
+ """Configure the root logger and library loggers in one place.
80
+
81
+ Args:
82
+ level: Log level name (``DEBUG``/``INFO``/...).
83
+ json_output: When ``True`` emit one JSON object per line; otherwise use
84
+ a human-readable format (useful in local development).
85
+ """
86
+ formatter: dict[str, Any]
87
+ if json_output:
88
+ formatter = {"()": "docsifer.logging_config.JsonFormatter"}
89
+ else:
90
+ formatter = {
91
+ "format": "%(asctime)s %(levelname)-8s %(name)s [%(request_id)s] %(message)s",
92
+ "datefmt": "%H:%M:%S",
93
+ }
94
+
95
+ config: dict[str, Any] = {
96
+ "version": 1,
97
+ "disable_existing_loggers": False,
98
+ "filters": {
99
+ "request_id": {"()": "docsifer.logging_config.RequestIdFilter"},
100
+ },
101
+ "formatters": {"default": formatter},
102
+ "handlers": {
103
+ "default": {
104
+ "class": "logging.StreamHandler",
105
+ "stream": sys.stdout,
106
+ "formatter": "default",
107
+ "filters": ["request_id"],
108
+ }
109
+ },
110
+ "loggers": {
111
+ "": {"handlers": ["default"], "level": level, "propagate": False},
112
+ "uvicorn": {"handlers": ["default"], "level": level, "propagate": False},
113
+ "uvicorn.error": {"handlers": ["default"], "level": level, "propagate": False},
114
+ "uvicorn.access": {"handlers": ["default"], "level": level, "propagate": False},
115
+ "gunicorn.error": {"handlers": ["default"], "level": level, "propagate": False},
116
+ "gunicorn.access": {"handlers": ["default"], "level": level, "propagate": False},
117
+ },
118
+ }
119
+
120
+ logging.config.dictConfig(config)
docsifer/main.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Application factory + ASGI entry point.
2
+
3
+ This module owns the FastAPI ``app`` instance, the lifespan-managed
4
+ singletons (``DocsiferService``, ``AnalyticsService``, safety primitives)
5
+ and the optional Gradio UI mount.
6
+
7
+ Run with::
8
+
9
+ uvicorn docsifer.main:app --host 0.0.0.0 --port 7860
10
+
11
+ or in production with::
12
+
13
+ gunicorn -k uvicorn.workers.UvicornWorker -w 4 docsifer.main:app
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import contextlib
20
+ import logging
21
+ from contextlib import asynccontextmanager
22
+ from typing import AsyncIterator
23
+
24
+ from fastapi import FastAPI
25
+ from fastapi.middleware.cors import CORSMiddleware
26
+ from fastapi.middleware.gzip import GZipMiddleware
27
+ from fastapi.responses import ORJSONResponse
28
+
29
+ from .analytics import AnalyticsService, InMemoryStore, UpstashStore
30
+ from .analytics.store import AnalyticsStore
31
+ from .api.error_handlers import register_exception_handlers
32
+ from .api.middleware import register_middleware
33
+ from .api.v1 import router as v1_router
34
+ from .config import Settings, get_settings
35
+ from .core.service import DocsiferService
36
+ from .logging_config import configure_logging
37
+ from .safety import (
38
+ ConversionGate,
39
+ PerIPLimiter,
40
+ ResourceGuard,
41
+ disk_cleanup_loop,
42
+ memory_watchdog_loop,
43
+ )
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ def _build_analytics_store(settings: Settings) -> AnalyticsStore:
49
+ if not settings.analytics_persistent:
50
+ logger.info("Analytics: in-memory (no DOCSIFER_REDIS_URL configured)")
51
+ return InMemoryStore()
52
+ try:
53
+ store = UpstashStore(url=settings.redis_url, token=settings.redis_token)
54
+ logger.info("Analytics: Upstash store configured")
55
+ return store
56
+ except Exception as exc: # pragma: no cover - defensive
57
+ logger.warning(
58
+ "Could not initialize Upstash store (%s); falling back to in-memory", exc
59
+ )
60
+ return InMemoryStore()
61
+
62
+
63
+ @asynccontextmanager
64
+ async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
65
+ settings: Settings = app.state.settings
66
+ configure_logging(level=settings.log_level, json_output=settings.log_json)
67
+ logger.info(
68
+ "Starting %s v%s (env=%s)",
69
+ settings.app_name,
70
+ settings.app_version,
71
+ settings.environment,
72
+ )
73
+
74
+ # ---- core converter -------------------------------------------------
75
+ converter = DocsiferService(
76
+ token_model=settings.token_model,
77
+ default_openai_base_url=settings.default_openai_base_url,
78
+ default_openai_model=settings.default_openai_model,
79
+ worker_pool_size=settings.worker_pool_size,
80
+ llm_cache_max_size=settings.llm_cache_max_size,
81
+ llm_cache_ttl=settings.llm_cache_ttl_sec,
82
+ openai_request_timeout=settings.openai_request_timeout_sec,
83
+ openai_connect_timeout=settings.openai_connect_timeout_sec,
84
+ openai_max_retries=settings.openai_max_retries,
85
+ known_extensions=set(settings.allowed_extensions),
86
+ )
87
+ app.state.converter = converter
88
+
89
+ # ---- analytics ------------------------------------------------------
90
+ store = _build_analytics_store(settings)
91
+ analytics = AnalyticsService(
92
+ store=store,
93
+ sync_interval_sec=settings.analytics_sync_interval_sec,
94
+ max_retries=settings.analytics_max_retries,
95
+ label=settings.analytics_label,
96
+ )
97
+ await analytics.start()
98
+ app.state.analytics = analytics
99
+
100
+ # ---- safety primitives ---------------------------------------------
101
+ app.state.conversion_gate = ConversionGate(
102
+ max_concurrent=settings.max_concurrent_conversions,
103
+ max_queue=settings.max_queue_depth,
104
+ )
105
+ app.state.per_ip_limiter = PerIPLimiter(
106
+ max_per_ip=settings.max_per_ip_concurrent,
107
+ )
108
+ app.state.resource_guard = ResourceGuard(
109
+ min_free_memory_mb=settings.min_free_memory_mb,
110
+ min_free_disk_mb=settings.min_free_disk_mb,
111
+ tmp_dir=settings.tmp_dir,
112
+ )
113
+
114
+ # ---- background loops ----------------------------------------------
115
+ stop_event = asyncio.Event()
116
+ background_tasks: list[asyncio.Task[None]] = [
117
+ asyncio.create_task(
118
+ disk_cleanup_loop(
119
+ tmp_dir=settings.tmp_dir,
120
+ ttl_sec=settings.disk_cleanup_ttl_sec,
121
+ interval_sec=settings.disk_cleanup_interval_sec,
122
+ stop_event=stop_event,
123
+ ),
124
+ name="docsifer-disk-cleanup",
125
+ )
126
+ ]
127
+ if settings.enable_memory_watchdog:
128
+ background_tasks.append(
129
+ asyncio.create_task(
130
+ memory_watchdog_loop(
131
+ threshold_pct=settings.memory_watchdog_pct,
132
+ interval_sec=settings.memory_watchdog_interval_sec,
133
+ stop_event=stop_event,
134
+ ),
135
+ name="docsifer-memory-watchdog",
136
+ )
137
+ )
138
+ app.state.stop_event = stop_event
139
+
140
+ try:
141
+ yield
142
+ finally:
143
+ logger.info("Shutting down %s", settings.app_name)
144
+ stop_event.set()
145
+ for task in background_tasks:
146
+ task.cancel()
147
+ with contextlib.suppress(asyncio.CancelledError, Exception):
148
+ await task
149
+ with contextlib.suppress(Exception):
150
+ await analytics.stop()
151
+ with contextlib.suppress(Exception):
152
+ await converter.shutdown()
153
+
154
+
155
+ def create_app(settings: Settings | None = None) -> FastAPI:
156
+ """Build and return the FastAPI application."""
157
+ settings = settings or get_settings()
158
+
159
+ app = FastAPI(
160
+ title=f"{settings.app_name} Service API",
161
+ description=(
162
+ "Convert PDF, PowerPoint, Word, Excel, images, audio, HTML, JSON, "
163
+ "CSV, XML, ZIP, and more into Markdown — optionally enhanced by an LLM."
164
+ ),
165
+ version=settings.app_version,
166
+ docs_url="/docs",
167
+ redoc_url="/redoc",
168
+ default_response_class=ORJSONResponse,
169
+ lifespan=_lifespan,
170
+ )
171
+ app.state.settings = settings
172
+
173
+ # CORS — fixes Bug A9 (allow_credentials with origins='*')
174
+ app.add_middleware(
175
+ CORSMiddleware,
176
+ allow_origins=settings.cors_origins,
177
+ allow_credentials=settings.cors_allow_credentials_safe,
178
+ allow_methods=["*"],
179
+ allow_headers=["*"],
180
+ expose_headers=["X-Request-ID"],
181
+ )
182
+ # GZip large responses (Section B.4)
183
+ app.add_middleware(GZipMiddleware, minimum_size=settings.gzip_min_size)
184
+
185
+ register_middleware(app, settings)
186
+ register_exception_handlers(app)
187
+
188
+ # ---- routes ---------------------------------------------------------
189
+ app.include_router(v1_router, prefix="/v1")
190
+
191
+ # ---- optional Gradio UI --------------------------------------------
192
+ _maybe_mount_gradio(app, settings)
193
+
194
+ return app
195
+
196
+
197
+ def _maybe_mount_gradio(app: FastAPI, settings: Settings) -> None:
198
+ try:
199
+ import gradio as gr # type: ignore
200
+
201
+ mount_fn = getattr(gr, "mount_gradio_app", None)
202
+ if mount_fn is None:
203
+ from gradio.routes import mount_gradio_app as mount_fn # type: ignore
204
+ from .ui.gradio_app import build_interface
205
+ except Exception as exc: # pragma: no cover - optional dependency
206
+ logger.warning("Gradio UI disabled (%s)", exc)
207
+ return
208
+
209
+ try:
210
+ interface = build_interface(settings, app)
211
+ mount_fn(app, interface, path="/")
212
+ logger.info("Gradio UI mounted at /")
213
+ except Exception as exc: # pragma: no cover - defensive
214
+ logger.warning("Could not mount Gradio UI (%s)", exc)
215
+
216
+
217
+ # Public ASGI app instance ----------------------------------------------------
218
+ app: FastAPI = create_app()
docsifer/router.py DELETED
@@ -1,129 +0,0 @@
1
- import logging
2
- import json
3
- import tempfile
4
- import os
5
- # import aiohttp
6
- from pathlib import Path
7
-
8
- from fastapi import APIRouter, HTTPException, UploadFile, File, Form, BackgroundTasks
9
- from pydantic import BaseModel
10
- # from scuid import scuid
11
-
12
- from .service import DocsiferService
13
- from .analytics import Analytics
14
-
15
- logger = logging.getLogger(__name__)
16
- router = APIRouter(tags=["v1"], responses={404: {"description": "Not found"}})
17
-
18
- # Initialize analytics (aggregated under "docsifer")
19
- analytics = Analytics(
20
- url=os.environ.get("REDIS_URL", "redis://localhost:6379/0"),
21
- token=os.environ.get("REDIS_TOKEN", "***"),
22
- sync_interval=30 * 60, # e.g. 30 minutes
23
- )
24
-
25
- # Initialize the Docsifer service (using "gpt-4o" for token counting)
26
- docsifer_service = DocsiferService(model_name="gpt-4o")
27
-
28
-
29
- class ConvertResponse(BaseModel):
30
- filename: str
31
- markdown: str
32
-
33
-
34
- @router.post("/convert", response_model=ConvertResponse)
35
- async def convert_document(
36
- background_tasks: BackgroundTasks,
37
- file: UploadFile = File(None, description="File to convert"),
38
- url: str = Form(
39
- None, description="URL to convert (used only if no file is provided)"
40
- ),
41
- openai: str = Form("{}", description="OpenAI config as a JSON object"),
42
- http: str = Form("{}", description="HTTP config as a JSON object"),
43
- settings: str = Form("{}", description="Settings as a JSON object"),
44
- ):
45
- """
46
- Convert a file or an HTML page from a URL into Markdown.
47
- If 'file' is provided, it takes priority over 'url'.
48
-
49
- - 'openai' is a JSON string with keys such as {"api_key": "...", "base_url": "..."}.
50
- - 'settings' is a JSON string with keys such as {"cleanup": bool}.
51
- """
52
- try:
53
- # Parse the JSON configuration parameters.
54
- try:
55
- openai_config = json.loads(openai) if openai else {}
56
- except json.JSONDecodeError:
57
- raise ValueError("Invalid JSON in 'openai' parameter.")
58
-
59
- try:
60
- http_config = json.loads(http) if http else {}
61
- except json.JSONDecodeError:
62
- raise ValueError("Invalid JSON in 'http' parameter.")
63
-
64
- try:
65
- settings_config = json.loads(settings) if settings else {}
66
- except json.JSONDecodeError:
67
- raise ValueError("Invalid JSON in 'settings' parameter.")
68
-
69
- cleanup = settings_config.get("cleanup", True)
70
-
71
- # If a file is provided, use it; otherwise, fetch the content from the URL.
72
- if file is not None:
73
- with tempfile.TemporaryDirectory() as tmpdir:
74
- temp_path = Path(tmpdir) / file.filename
75
- contents = await file.read()
76
- temp_path.write_bytes(contents)
77
- result, token_count = await docsifer_service.convert_file(
78
- source=str(temp_path),
79
- openai_config=openai_config,
80
- http_config=http_config,
81
- cleanup=cleanup,
82
- )
83
- elif url:
84
- # async with aiohttp.ClientSession() as session:
85
- # async with session.get(url) as resp:
86
- # if resp.status != 200:
87
- # raise ValueError(f"Failed to fetch URL: status {resp.status}")
88
- # data = await resp.read()
89
- # with tempfile.TemporaryDirectory() as tmpdir:
90
- # temp_path = Path(tmpdir) / f"{scuid()}.html"
91
- # temp_path.write_bytes(data)
92
- # result, token_count = await docsifer_service.convert_file(
93
- # source=str(temp_path),
94
- # openai_config=openai_config,
95
- # cleanup=cleanup,
96
- # )
97
- result, token_count = await docsifer_service.convert_file(
98
- source=str(url),
99
- openai_config=openai_config,
100
- http_config=http_config,
101
- cleanup=cleanup,
102
- )
103
- else:
104
- raise HTTPException(
105
- status_code=400, detail="Provide either 'file' or 'url'."
106
- )
107
-
108
- # Record token usage in the background.
109
- background_tasks.add_task(analytics.access, token_count)
110
- return ConvertResponse(**result)
111
-
112
- except Exception as e:
113
- msg = f"Failed to convert content. Error: {str(e)}"
114
- logger.error(msg)
115
- raise HTTPException(status_code=500, detail=msg)
116
-
117
-
118
- @router.get("/stats")
119
- async def get_stats():
120
- """
121
- Return usage statistics (access, tokens) from the Analytics system.
122
- """
123
- try:
124
- data = await analytics.stats()
125
- return data
126
- except Exception as e:
127
- msg = f"Failed to fetch analytics stats: {str(e)}"
128
- logger.error(msg)
129
- raise HTTPException(status_code=500, detail=msg)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docsifer/safety/__init__.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Anti-crash safety primitives (resource guard, queue gate, watchdog, …)."""
2
+
3
+ from .circuit_breaker import CircuitBreaker
4
+ from .conversion_gate import ConversionGate
5
+ from .disk_cleanup import disk_cleanup_loop
6
+ from .memory_watchdog import memory_watchdog_loop
7
+ from .per_ip_limiter import PerIPLimiter
8
+ from .resource_guard import ResourceGuard
9
+
10
+ __all__ = [
11
+ "CircuitBreaker",
12
+ "ConversionGate",
13
+ "PerIPLimiter",
14
+ "ResourceGuard",
15
+ "disk_cleanup_loop",
16
+ "memory_watchdog_loop",
17
+ ]
docsifer/safety/circuit_breaker.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Circuit breaker for upstream dependencies (Section N.8)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from typing import Awaitable, Callable, TypeVar
8
+
9
+ from ..exceptions import CircuitOpenError
10
+
11
+ T = TypeVar("T")
12
+
13
+
14
+ class CircuitBreaker:
15
+ """Three-state circuit breaker (closed → open → half-open)."""
16
+
17
+ def __init__(
18
+ self,
19
+ *,
20
+ failure_threshold: int = 5,
21
+ reset_timeout_sec: float = 60.0,
22
+ ) -> None:
23
+ self._failure_threshold = max(1, failure_threshold)
24
+ self._reset_timeout = max(1.0, reset_timeout_sec)
25
+ self._failures = 0
26
+ self._opened_at: float | None = None
27
+ self._half_open = False
28
+ self._lock = asyncio.Lock()
29
+
30
+ @property
31
+ def state(self) -> str:
32
+ if self._opened_at is None:
33
+ return "closed"
34
+ if self._half_open:
35
+ return "half-open"
36
+ return "open"
37
+
38
+ async def call(self, fn: Callable[..., Awaitable[T]], *args, **kwargs) -> T:
39
+ async with self._lock:
40
+ if self._opened_at is not None and not self._half_open:
41
+ if time.monotonic() - self._opened_at >= self._reset_timeout:
42
+ self._half_open = True
43
+ else:
44
+ raise CircuitOpenError("Upstream is unavailable")
45
+
46
+ try:
47
+ result = await fn(*args, **kwargs)
48
+ except Exception:
49
+ await self._on_failure()
50
+ raise
51
+ else:
52
+ await self._on_success()
53
+ return result
54
+
55
+ async def _on_success(self) -> None:
56
+ async with self._lock:
57
+ self._failures = 0
58
+ self._opened_at = None
59
+ self._half_open = False
60
+
61
+ async def _on_failure(self) -> None:
62
+ async with self._lock:
63
+ self._failures += 1
64
+ if self._failures >= self._failure_threshold:
65
+ self._opened_at = time.monotonic()
66
+ self._half_open = False
docsifer/safety/conversion_gate.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bounded global concurrency / queue (Section N.5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ from typing import AsyncIterator
8
+
9
+ from ..exceptions import QueueFullError
10
+
11
+
12
+ class ConversionGate:
13
+ """Cap concurrent conversions and queue depth, fail fast otherwise."""
14
+
15
+ def __init__(self, *, max_concurrent: int, max_queue: int) -> None:
16
+ if max_concurrent <= 0:
17
+ raise ValueError("max_concurrent must be positive")
18
+ self._semaphore = asyncio.Semaphore(max_concurrent)
19
+ self._max_concurrent = max_concurrent
20
+ self._max_queue = max(0, max_queue)
21
+ self._waiting = 0
22
+ self._inflight = 0
23
+ self._lock = asyncio.Lock()
24
+
25
+ @property
26
+ def stats(self) -> dict[str, int]:
27
+ return {
28
+ "max_concurrent": self._max_concurrent,
29
+ "max_queue": self._max_queue,
30
+ "waiting": self._waiting,
31
+ "inflight": self._inflight,
32
+ }
33
+
34
+ @contextlib.asynccontextmanager
35
+ async def acquire(self) -> AsyncIterator[None]:
36
+ async with self._lock:
37
+ in_use = self._inflight + self._waiting
38
+ # Queue is engaged only after capacity is fully used.
39
+ queued = max(0, in_use - self._max_concurrent)
40
+ if queued >= self._max_queue and in_use >= self._max_concurrent:
41
+ raise QueueFullError(
42
+ "Conversion queue full",
43
+ details={"max_queue": self._max_queue},
44
+ )
45
+ self._waiting += 1
46
+
47
+ try:
48
+ await self._semaphore.acquire()
49
+ except BaseException:
50
+ async with self._lock:
51
+ self._waiting -= 1
52
+ raise
53
+
54
+ async with self._lock:
55
+ self._waiting -= 1
56
+ self._inflight += 1
57
+ try:
58
+ yield
59
+ finally:
60
+ async with self._lock:
61
+ self._inflight -= 1
62
+ self._semaphore.release()
docsifer/safety/disk_cleanup.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Background disk cleanup loop (Section N.11).
2
+
3
+ Removes stale files matching ``docsifer-*`` in the configured tmp directory so
4
+ long-running containers don't accumulate gigabytes of leftovers.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ import time
12
+ from pathlib import Path
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ async def disk_cleanup_loop(
18
+ *,
19
+ tmp_dir: Path,
20
+ ttl_sec: int = 3600,
21
+ interval_sec: int = 600,
22
+ pattern: str = "docsifer-*",
23
+ stop_event: asyncio.Event | None = None,
24
+ ) -> None:
25
+ """Sweep ``tmp_dir`` every ``interval_sec`` and unlink stale temp files."""
26
+ stop = stop_event or asyncio.Event()
27
+ while not stop.is_set():
28
+ try:
29
+ await asyncio.wait_for(stop.wait(), timeout=interval_sec)
30
+ return
31
+ except asyncio.TimeoutError:
32
+ pass
33
+
34
+ try:
35
+ now = time.time()
36
+ removed = 0
37
+ for entry in tmp_dir.glob(pattern):
38
+ try:
39
+ if now - entry.stat().st_mtime > ttl_sec:
40
+ if entry.is_file():
41
+ entry.unlink(missing_ok=True)
42
+ removed += 1
43
+ except OSError as exc:
44
+ logger.debug("Could not remove %s: %s", entry, exc)
45
+ if removed:
46
+ logger.info("Disk cleanup removed %d stale file(s)", removed)
47
+ except Exception as exc:
48
+ logger.warning("Disk cleanup failed: %s", exc)
docsifer/safety/memory_watchdog.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Memory watchdog — graceful self-restart before the kernel OOM-kills us
2
+ (Section N.13).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import logging
9
+ import os
10
+ import signal
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ try: # pragma: no cover - environment dependent
15
+ import psutil
16
+
17
+ _HAS_PSUTIL = True
18
+ except Exception: # pragma: no cover
19
+ psutil = None # type: ignore[assignment]
20
+ _HAS_PSUTIL = False
21
+
22
+
23
+ async def memory_watchdog_loop(
24
+ *,
25
+ threshold_pct: float = 90.0,
26
+ interval_sec: int = 30,
27
+ stop_event: asyncio.Event | None = None,
28
+ ) -> None:
29
+ """Periodically inspect process RSS and request graceful restart on overrun."""
30
+ if not _HAS_PSUTIL:
31
+ logger.info("psutil unavailable; memory watchdog disabled")
32
+ return
33
+
34
+ proc = psutil.Process()
35
+ stop = stop_event or asyncio.Event()
36
+ while not stop.is_set():
37
+ try:
38
+ await asyncio.wait_for(stop.wait(), timeout=interval_sec)
39
+ return
40
+ except asyncio.TimeoutError:
41
+ pass
42
+
43
+ try:
44
+ usage = proc.memory_percent()
45
+ except Exception as exc: # pragma: no cover - defensive
46
+ logger.debug("memory_percent failed: %s", exc)
47
+ continue
48
+
49
+ if usage > threshold_pct:
50
+ logger.warning(
51
+ "Memory pressure %.1f%% > %.1f%% — sending SIGTERM",
52
+ usage,
53
+ threshold_pct,
54
+ )
55
+ os.kill(os.getpid(), signal.SIGTERM)
56
+ return
docsifer/safety/per_ip_limiter.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-IP fairness — limit how many conversions a single IP can run at once
2
+ (Section N.6).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import contextlib
9
+ import time
10
+ from typing import AsyncIterator
11
+
12
+ from ..exceptions import TooManyRequestsError
13
+
14
+
15
+ class _Slot:
16
+ __slots__ = ("active", "last_seen")
17
+
18
+ def __init__(self) -> None:
19
+ self.active = 0
20
+ self.last_seen = time.monotonic()
21
+
22
+
23
+ class PerIPLimiter:
24
+ """Bound the number of concurrent in-flight requests per IP."""
25
+
26
+ def __init__(self, *, max_per_ip: int, idle_evict_sec: int = 600) -> None:
27
+ self._max_per_ip = max(1, max_per_ip)
28
+ self._slots: dict[str, _Slot] = {}
29
+ self._lock = asyncio.Lock()
30
+ self._idle_evict_sec = idle_evict_sec
31
+
32
+ @contextlib.asynccontextmanager
33
+ async def acquire(self, ip: str) -> AsyncIterator[None]:
34
+ await self._enter(ip)
35
+ try:
36
+ yield
37
+ finally:
38
+ await self._leave(ip)
39
+
40
+ async def _enter(self, ip: str) -> None:
41
+ async with self._lock:
42
+ self._evict_idle()
43
+ slot = self._slots.setdefault(ip, _Slot())
44
+ if slot.active >= self._max_per_ip:
45
+ raise TooManyRequestsError(
46
+ "Too many concurrent requests for this client",
47
+ details={"max_per_ip": self._max_per_ip},
48
+ )
49
+ slot.active += 1
50
+ slot.last_seen = time.monotonic()
51
+
52
+ async def _leave(self, ip: str) -> None:
53
+ async with self._lock:
54
+ slot = self._slots.get(ip)
55
+ if slot is None:
56
+ return
57
+ slot.active = max(0, slot.active - 1)
58
+ slot.last_seen = time.monotonic()
59
+
60
+ def _evict_idle(self) -> None:
61
+ now = time.monotonic()
62
+ cutoff = now - self._idle_evict_sec
63
+ stale = [ip for ip, s in self._slots.items() if s.active == 0 and s.last_seen < cutoff]
64
+ for ip in stale:
65
+ self._slots.pop(ip, None)
docsifer/safety/resource_guard.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Memory / disk admission control to prevent OOM (Section N.4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ from ..exceptions import ResourceExhaustedError
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ try: # pragma: no cover - environment dependent
13
+ import psutil
14
+
15
+ _HAS_PSUTIL = True
16
+ except Exception: # pragma: no cover
17
+ psutil = None # type: ignore[assignment]
18
+ _HAS_PSUTIL = False
19
+
20
+
21
+ class ResourceGuard:
22
+ """Reject new conversions when the host is approaching exhaustion."""
23
+
24
+ def __init__(
25
+ self,
26
+ *,
27
+ min_free_memory_mb: int,
28
+ min_free_disk_mb: int,
29
+ tmp_dir: Path,
30
+ memory_overhead_factor: int = 3,
31
+ memory_overhead_floor_mb: int = 256,
32
+ ) -> None:
33
+ self._min_free_memory_mb = min_free_memory_mb
34
+ self._min_free_disk_mb = min_free_disk_mb
35
+ self._tmp_dir = tmp_dir
36
+ self._overhead_factor = memory_overhead_factor
37
+ self._overhead_floor_mb = memory_overhead_floor_mb
38
+
39
+ def check(self, expected_size_bytes: int = 0) -> None:
40
+ """Raise :class:`ResourceExhaustedError` when limits are exceeded."""
41
+ if not _HAS_PSUTIL:
42
+ return # cannot enforce without psutil; fail-open
43
+
44
+ # Memory
45
+ avail_mb = psutil.virtual_memory().available // (1024 * 1024)
46
+ size_mb = expected_size_bytes // (1024 * 1024)
47
+ needed_mb = max(
48
+ self._min_free_memory_mb,
49
+ size_mb * self._overhead_factor + self._overhead_floor_mb,
50
+ )
51
+ if avail_mb < needed_mb:
52
+ raise ResourceExhaustedError(
53
+ f"Insufficient memory: need {needed_mb} MB, have {avail_mb} MB",
54
+ details={"available_mb": avail_mb, "needed_mb": needed_mb},
55
+ )
56
+
57
+ # Disk
58
+ try:
59
+ disk_free_mb = psutil.disk_usage(str(self._tmp_dir)).free // (1024 * 1024)
60
+ except Exception as exc:
61
+ logger.debug("disk_usage check failed: %s", exc)
62
+ return
63
+ if disk_free_mb < self._min_free_disk_mb:
64
+ raise ResourceExhaustedError(
65
+ f"Insufficient disk space: {disk_free_mb} MB free",
66
+ details={"disk_free_mb": disk_free_mb},
67
+ )
docsifer/service.py DELETED
@@ -1,227 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import asyncio
4
- import logging
5
-
6
- # import tempfile
7
-
8
- import requests.cookies
9
- import magic
10
- import mimetypes
11
- import requests
12
- from pathlib import Path
13
- from typing import Optional, Dict, Tuple, Any
14
- from scuid import scuid
15
-
16
- import tiktoken
17
- from pyquery import PyQuery as pq
18
- from markitdown import MarkItDown
19
- from openai import OpenAI
20
-
21
- logger = logging.getLogger(__name__)
22
- logging.basicConfig(level=logging.INFO)
23
-
24
-
25
- class DocsiferService:
26
- """
27
- A service that converts local files to Markdown using MarkItDown,
28
- optionally with an OpenAI LLM for advanced extraction.
29
- Token counting uses a tiktoken encoder (heuristically with the provided model).
30
- """
31
-
32
- def __init__(self, model_name: str = "gpt-4o"):
33
- """
34
- Initialize the DocsiferService with a basic MarkItDown instance
35
- and a tiktoken encoder for counting tokens using the provided model.
36
- """
37
- self._basic_markitdown = MarkItDown() # MarkItDown without LLM
38
-
39
- # Use the given model for token counting
40
- try:
41
- self._encoder = tiktoken.encoding_for_model(model_name)
42
- except Exception as e:
43
- logger.warning(
44
- "Error loading tiktoken model '%s': %s. Falling back to 'gpt-3.5-turbo-0301'.",
45
- model_name,
46
- e,
47
- )
48
- self._encoder = tiktoken.encoding_for_model("gpt-3.5-turbo-0301")
49
-
50
- logger.info("DocsiferService initialized with token model '%s'.", model_name)
51
-
52
- def _init_markitdown_with_llm(self, openai_config: Dict[str, Any]) -> MarkItDown:
53
- """
54
- Initialize a MarkItDown instance configured with an OpenAI LLM if an API key is provided.
55
-
56
- Args:
57
- openai_config: A dictionary containing OpenAI configuration (e.g., api_key, model, base_url).
58
-
59
- Returns:
60
- A MarkItDown instance configured with the OpenAI client, or the basic instance if no key is provided.
61
- """
62
- api_key = openai_config.get("api_key", "")
63
- if not api_key:
64
- logger.info("No OpenAI API key provided. Using basic MarkItDown.")
65
- return self._basic_markitdown
66
-
67
- model = openai_config.get("model", "gpt-4o-mini")
68
- base_url = openai_config.get("base_url", "https://api.openai.com/v1")
69
- client = OpenAI(api_key=api_key, base_url=base_url)
70
-
71
- logger.info("Initialized OpenAI with base_url=%s", base_url)
72
- return MarkItDown(llm_client=client, llm_model=model)
73
-
74
- def _maybe_cleanup_html(self, html_file: Path) -> None:
75
- """
76
- If the file is HTML, remove <style> tags and hidden elements to clean up the content.
77
-
78
- Args:
79
- html_file: Path to the HTML file.
80
- """
81
- try:
82
- content = html_file.read_text(encoding="utf-8", errors="ignore")
83
- d = pq(content)
84
- # Remove hidden elements and inline styles that hide content.
85
- d(":hidden").remove()
86
- d("[style='display:none']").remove()
87
- d('*[style*="display:none"]').remove()
88
- d("style").remove()
89
- cleaned_html = str(d).strip()
90
- html_file.write_text(cleaned_html, encoding="utf-8")
91
- except Exception as e:
92
- logger.error("HTML cleanup failed for %s: %s", html_file, e)
93
-
94
- def _count_tokens(self, text: str) -> int:
95
- """
96
- Count tokens in the given text using the configured tiktoken encoder.
97
- Falls back to a whitespace-based count if an error occurs.
98
-
99
- Args:
100
- text: The text to count tokens in.
101
-
102
- Returns:
103
- The number of tokens.
104
- """
105
- try:
106
- return len(self._encoder.encode(text))
107
- except Exception as e:
108
- logger.warning(
109
- "Token counting failed, fallback to whitespace. Error: %s", e
110
- )
111
- return len(text.split())
112
-
113
- def _convert_sync(
114
- self,
115
- source: str,
116
- openai_config: Optional[dict] = None,
117
- http_config: Optional[dict] = None,
118
- cleanup: bool = True,
119
- ) -> Tuple[Dict[str, str], int]:
120
- """
121
- Synchronously convert a file at `file_path` to Markdown.
122
- This helper method performs blocking file I/O, MIME detection, temporary file handling,
123
- optional HTML cleanup, and MarkItDown conversion.
124
-
125
- Args:
126
- source: Path to the source file or URL to fetch content from.
127
- openai_config: Optional dictionary with OpenAI configuration.
128
- http_config: Optional dictionary with HTTP configuration.
129
- cleanup: Whether to perform HTML cleanup if the file is an HTML file.
130
-
131
- Returns:
132
- A tuple containing a dictionary with keys "filename" and "markdown", and the token count.
133
- """
134
- if source.startswith("http"):
135
- filename = f"{scuid()}.html"
136
- else:
137
- src = Path(source)
138
- if not src.exists():
139
- raise FileNotFoundError(f"File not found: {source}")
140
-
141
- logger.info("Converting file: %s (cleanup=%s)", source, cleanup)
142
-
143
- mime_type = magic.from_file(str(src), mime=True)
144
- guessed_ext = mimetypes.guess_extension(mime_type) or ".tmp"
145
- if not mime_type:
146
- logger.warning(f"Could not detect file type for: {src}")
147
- new_filename = src.name
148
- else:
149
- logger.debug(f"Detected MIME type '{mime_type}' for: {src}")
150
- new_filename = f"{src.stem}{guessed_ext}"
151
- tmp_path = src.parent / new_filename
152
- tmp_path.write_bytes(src.read_bytes())
153
- # src.unlink()
154
-
155
- logger.info(
156
- "Using temp file: %s, MIME type: %s, Guessed ext: %s, Existing: %s, Source size: %s",
157
- tmp_path,
158
- mime_type,
159
- guessed_ext,
160
- tmp_path.exists(),
161
- src.stat().st_size,
162
- )
163
-
164
- # Perform HTML cleanup if requested.
165
- if cleanup and guessed_ext.lower() in (".html", ".htm"):
166
- self._maybe_cleanup_html(tmp_path)
167
-
168
- filename = new_filename
169
- source = tmp_path
170
-
171
- # Decide whether to use LLM-enhanced conversion or the basic converter.
172
- if openai_config and openai_config.get("api_key"):
173
- md_converter = self._init_markitdown_with_llm(openai_config)
174
- else:
175
- md_converter = self._basic_markitdown
176
-
177
- # Load cookies if provided in the HTTP config.
178
- if http_config:
179
- if "cookies" in http_config:
180
- requests.cookies.cookiejar_from_dict(
181
- http_config["cookies"],
182
- requests.cookies.RequestsCookieJar,
183
- overwrite=True,
184
- )
185
-
186
- try:
187
- result_obj = md_converter.convert(source)
188
- except Exception as e:
189
- logger.error("MarkItDown conversion failed: %s", e)
190
- raise RuntimeError(f"Conversion failed for '{source}': {e}")
191
-
192
- if isinstance(source, Path) and source.exists():
193
- source.unlink()
194
-
195
- # Count tokens in the resulting markdown text.
196
- token_count = self._count_tokens(result_obj.text_content)
197
-
198
- result_dict = {
199
- "filename": filename,
200
- "markdown": result_obj.text_content,
201
- }
202
- return result_dict, token_count
203
-
204
- async def convert_file(
205
- self,
206
- source: str,
207
- openai_config: Optional[dict] = None,
208
- http_config: Optional[dict] = None,
209
- cleanup: bool = True,
210
- ) -> Tuple[Dict[str, str], int]:
211
- """
212
- Asynchronously convert a file at `source` to Markdown.
213
- This method offloads the blocking conversion process to a separate thread.
214
-
215
- Args:
216
- source: Path to the file to convert or a URL to fetch content from.
217
- openai_config: Optional OpenAI configuration dictionary.
218
- http_config: Optional HTTP configuration dictionary.
219
- cleanup: Whether to perform HTML cleanup if applicable.
220
-
221
- Returns:
222
- A tuple containing the result dictionary (with keys "filename" and "markdown")
223
- and the token count.
224
- """
225
- return await asyncio.to_thread(
226
- self._convert_sync, source, openai_config, http_config, cleanup
227
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docsifer/ui/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Gradio UI layer (optional)."""
2
+
3
+ from .gradio_app import build_interface
4
+
5
+ __all__ = ["build_interface"]
docsifer/ui/gradio_app.py ADDED
@@ -0,0 +1,385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio playground.
2
+
3
+ A refined, focused UI: one screen for conversion, one for stats. The handlers
4
+ call :class:`DocsiferService` directly (no HTTP loopback), so the browser
5
+ talks to FastAPI which talks to the in-process service.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import logging
13
+ import secrets
14
+ import shutil
15
+ import tempfile
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import gradio as gr
20
+ import pandas as pd
21
+ from fastapi import FastAPI
22
+
23
+ from ..analytics import AnalyticsService
24
+ from ..config import Settings
25
+ from ..core.service import DocsiferService
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Theme & copy
32
+ # ---------------------------------------------------------------------------
33
+ _THEME = gr.themes.Soft(
34
+ primary_hue=gr.themes.colors.indigo,
35
+ secondary_hue=gr.themes.colors.violet,
36
+ neutral_hue=gr.themes.colors.slate,
37
+ radius_size=gr.themes.sizes.radius_lg,
38
+ font=("Inter", "ui-sans-serif", "system-ui", "sans-serif"),
39
+ )
40
+
41
+ _HERO = """
42
+ <div style="text-align:center; padding: 8px 0 24px;">
43
+ <h1 style="margin:0; font-weight:700; letter-spacing:-0.02em;">📚 Docsifer</h1>
44
+ <p style="margin:6px 0 0; opacity:0.8; font-size:1.05rem;">
45
+ Convert documents into clean, LLM-ready Markdown.
46
+ </p>
47
+ <p style="margin:4px 0 0; opacity:0.6; font-size:0.9rem;">
48
+ PDF · Word · PowerPoint · Excel · HTML · Audio · Image · CSV · JSON · ZIP
49
+ </p>
50
+ </div>
51
+ """
52
+
53
+ _FOOTER = """
54
+ <div style="text-align:center; opacity:0.55; font-size:0.85rem; padding: 16px 0 4px;">
55
+ Powered by <a href="https://github.com/microsoft/markitdown" target="_blank">MarkItDown</a> ·
56
+ <a href="https://github.com/lh0x00/docsifer" target="_blank">Source</a> ·
57
+ Files are processed in-memory and discarded immediately.
58
+ </div>
59
+ """
60
+
61
+ _CSS = """
62
+ .gradio-container { max-width: 1200px !important; margin: auto; }
63
+ #convert-btn { font-weight: 600; letter-spacing: 0.01em; }
64
+ .markdown-out textarea { font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; font-size: 0.92rem; }
65
+ .compact-tabs button.selected { font-weight: 600; }
66
+ """
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Helpers
71
+ # ---------------------------------------------------------------------------
72
+ def _df_from_bucket(bucket: dict[str, dict[str, int]]) -> pd.DataFrame:
73
+ columns = ["Model", "Total", "Daily", "Weekly", "Monthly", "Yearly"]
74
+ rows: list[list[Any]] = []
75
+ models: set[str] = set()
76
+ for period in ("total", "daily", "weekly", "monthly", "yearly"):
77
+ models.update((bucket.get(period) or {}).keys())
78
+ for model in sorted(models):
79
+ rows.append(
80
+ [
81
+ model,
82
+ bucket.get("total", {}).get(model, 0),
83
+ bucket.get("daily", {}).get(model, 0),
84
+ bucket.get("weekly", {}).get(model, 0),
85
+ bucket.get("monthly", {}).get(model, 0),
86
+ bucket.get("yearly", {}).get(model, 0),
87
+ ]
88
+ )
89
+ if not rows:
90
+ rows.append(["docsifer", 0, 0, 0, 0, 0])
91
+ return pd.DataFrame(rows, columns=columns)
92
+
93
+
94
+ def _build_openai_dict(
95
+ base_url: str | None,
96
+ api_key: str | None,
97
+ model: str | None,
98
+ ) -> dict[str, Any] | None:
99
+ """Only forward the OpenAI config when an API key is provided (Bug A1)."""
100
+ api_key = (api_key or "").strip()
101
+ if not api_key:
102
+ return None
103
+ cfg: dict[str, Any] = {"api_key": api_key}
104
+ if base_url and base_url.strip():
105
+ cfg["base_url"] = base_url.strip()
106
+ if model and model.strip():
107
+ cfg["model"] = model.strip()
108
+ return cfg
109
+
110
+
111
+ def _parse_cookies(raw: str | None) -> dict[str, Any] | None:
112
+ raw = (raw or "").strip()
113
+ if not raw:
114
+ return None
115
+ try:
116
+ return json.loads(raw)
117
+ except json.JSONDecodeError as exc:
118
+ raise ValueError(f"Invalid JSON for HTTP cookies: {exc}") from exc
119
+
120
+
121
+ def _write_markdown_file(text: str, *, tmp_dir: Path, stem: str) -> str:
122
+ safe_stem = "".join(c for c in stem if c.isalnum() or c in "-_") or "document"
123
+ name = f"docsifer-{safe_stem}-{secrets.token_hex(4)}.md"
124
+ dst = tmp_dir / name
125
+ dst.write_text(text, encoding="utf-8")
126
+ return str(dst)
127
+
128
+
129
+ def _status(text: str, *, level: str = "info") -> str:
130
+ palette = {
131
+ "info": "#6366f1",
132
+ "ok": "#16a34a",
133
+ "warn": "#d97706",
134
+ "err": "#dc2626",
135
+ }
136
+ color = palette.get(level, palette["info"])
137
+ return (
138
+ f'<div style="padding:10px 14px;border-radius:10px;'
139
+ f'background:{color}14;color:{color};font-size:0.9rem;'
140
+ f'border:1px solid {color}30;">{text}</div>'
141
+ )
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Builder
146
+ # ---------------------------------------------------------------------------
147
+ def build_interface(settings: Settings, app: FastAPI) -> gr.Blocks:
148
+ """Construct the Gradio interface bound to the running FastAPI app."""
149
+
150
+ def _service() -> DocsiferService:
151
+ return app.state.converter # type: ignore[no-any-return]
152
+
153
+ def _analytics() -> AnalyticsService:
154
+ return app.state.analytics # type: ignore[no-any-return]
155
+
156
+ async def _run_conversion(
157
+ file_path: str | None,
158
+ url_str: str | None,
159
+ base_url: str,
160
+ api_key: str,
161
+ model_id: str,
162
+ cleanup: bool,
163
+ http_cookies: str,
164
+ ) -> tuple[str, str | None, str]:
165
+ if not file_path and not (url_str and url_str.strip()):
166
+ return "", None, _status("Please provide a file or a URL.", level="warn")
167
+
168
+ try:
169
+ openai_cfg = _build_openai_dict(base_url, api_key, model_id)
170
+ cookies = _parse_cookies(http_cookies)
171
+ http_cfg = {"cookies": cookies} if cookies else None
172
+ except ValueError as exc:
173
+ return "", None, _status(str(exc), level="err")
174
+
175
+ managed_dir = Path(tempfile.mkdtemp(prefix="docsifer-ui-", dir=settings.tmp_dir))
176
+ try:
177
+ if file_path:
178
+ src = Path(file_path)
179
+ staged = managed_dir / src.name
180
+ shutil.copyfile(src, staged)
181
+ source: str | Path = staged
182
+ stem = src.stem
183
+ else:
184
+ source = (url_str or "").strip()
185
+ stem = "url"
186
+
187
+ try:
188
+ result = await asyncio.wait_for(
189
+ _service().convert_file(
190
+ source,
191
+ openai_config=openai_cfg,
192
+ http_config=http_cfg,
193
+ cleanup_html=bool(cleanup),
194
+ ),
195
+ timeout=settings.request_timeout_sec,
196
+ )
197
+ except asyncio.TimeoutError:
198
+ return (
199
+ "",
200
+ None,
201
+ _status(
202
+ f"Conversion timed out after {settings.request_timeout_sec}s. "
203
+ "Try a smaller file.",
204
+ level="err",
205
+ ),
206
+ )
207
+ except Exception as exc: # noqa: BLE001
208
+ logger.exception("Gradio conversion failed")
209
+ return "", None, _status(f"Conversion failed: {exc}", level="err")
210
+
211
+ asyncio.create_task(_record_access(result.token_count))
212
+ md_path = _write_markdown_file(
213
+ result.markdown, tmp_dir=settings.tmp_dir, stem=stem
214
+ )
215
+ byte_len = len(result.markdown.encode("utf-8"))
216
+ ok_msg = (
217
+ f"Done — {len(result.markdown):,} chars · "
218
+ f"{byte_len/1024:.1f} KB · ~{result.token_count:,} tokens"
219
+ )
220
+ return result.markdown, md_path, _status(ok_msg, level="ok")
221
+ finally:
222
+ shutil.rmtree(managed_dir, ignore_errors=True)
223
+
224
+ async def _record_access(tokens: int) -> None:
225
+ try:
226
+ await _analytics().access(tokens)
227
+ except Exception:
228
+ logger.exception("Analytics access failed (UI)")
229
+
230
+ async def _fetch_stats() -> tuple[pd.DataFrame, pd.DataFrame, str]:
231
+ snap = await _analytics().stats()
232
+ access_df = _df_from_bucket(snap.get("access", {}))
233
+ tokens_df = _df_from_bucket(snap.get("tokens", {}))
234
+ msg = (
235
+ _status("Analytics backend healthy.", level="ok")
236
+ if snap.get("healthy", True)
237
+ else _status("Analytics degraded — using in-memory snapshot.", level="warn")
238
+ )
239
+ return access_df, tokens_df, msg
240
+
241
+ # ------------------------------------------------------------------
242
+ # Layout
243
+ # ------------------------------------------------------------------
244
+ with gr.Blocks(
245
+ title="Docsifer — Document to Markdown",
246
+ theme=_THEME,
247
+ css=_CSS,
248
+ analytics_enabled=False,
249
+ ) as demo:
250
+ gr.HTML(_HERO)
251
+
252
+ with gr.Tabs(elem_classes=["compact-tabs"]):
253
+ # ---- Convert tab ------------------------------------------------
254
+ with gr.Tab("Convert"):
255
+ with gr.Row(equal_height=False):
256
+ with gr.Column(scale=5, min_width=320):
257
+ file_input = gr.File(
258
+ label="File",
259
+ file_types=settings.allowed_extensions,
260
+ type="filepath",
261
+ file_count="single",
262
+ height=120,
263
+ )
264
+ url_input = gr.Textbox(
265
+ label="…or a URL",
266
+ placeholder="https://example.com/article",
267
+ lines=1,
268
+ )
269
+
270
+ with gr.Accordion("LLM (optional)", open=False):
271
+ gr.Markdown(
272
+ "Provide an OpenAI-compatible key for OCR / "
273
+ "layout / transcription. Leave blank for basic mode.",
274
+ elem_classes=["help-text"],
275
+ )
276
+ openai_api_key = gr.Textbox(
277
+ label="API key",
278
+ placeholder="sk-…",
279
+ type="password",
280
+ )
281
+ with gr.Row():
282
+ openai_base_url = gr.Textbox(
283
+ label="Base URL",
284
+ value=settings.default_openai_base_url,
285
+ scale=3,
286
+ )
287
+ openai_model = gr.Textbox(
288
+ label="Model",
289
+ value=settings.default_openai_model,
290
+ scale=2,
291
+ )
292
+
293
+ with gr.Accordion("Advanced", open=False):
294
+ cleanup_toggle = gr.Checkbox(
295
+ label="Strip script / style / hidden HTML",
296
+ value=True,
297
+ )
298
+ http_cookies = gr.Textbox(
299
+ label="HTTP cookies (JSON, optional)",
300
+ placeholder='{"session": "abc..."}',
301
+ lines=2,
302
+ )
303
+
304
+ convert_btn = gr.Button(
305
+ "Convert to Markdown",
306
+ variant="primary",
307
+ elem_id="convert-btn",
308
+ size="lg",
309
+ )
310
+ status_box = gr.HTML(value="", visible=True)
311
+
312
+ with gr.Column(scale=7, min_width=380):
313
+ output_md = gr.Textbox(
314
+ label="Markdown",
315
+ lines=24,
316
+ max_lines=40,
317
+ interactive=True,
318
+ show_copy_button=True,
319
+ elem_classes=["markdown-out"],
320
+ placeholder="Your converted Markdown will appear here…",
321
+ )
322
+ download_file = gr.File(
323
+ label="Download",
324
+ interactive=False,
325
+ height=70,
326
+ )
327
+
328
+ convert_btn.click(
329
+ fn=_run_conversion,
330
+ inputs=[
331
+ file_input,
332
+ url_input,
333
+ openai_base_url,
334
+ openai_api_key,
335
+ openai_model,
336
+ cleanup_toggle,
337
+ http_cookies,
338
+ ],
339
+ outputs=[output_md, download_file, status_box],
340
+ api_name=False,
341
+ show_progress="full",
342
+ )
343
+
344
+ # ---- Stats tab --------------------------------------------------
345
+ with gr.Tab("Stats"):
346
+ gr.Markdown(
347
+ "Aggregated usage counters (per model and period).",
348
+ )
349
+ stats_status = gr.HTML(value="")
350
+ stats_btn = gr.Button("Refresh", variant="secondary", size="sm")
351
+ with gr.Row():
352
+ access_df = gr.DataFrame(
353
+ label="Access count",
354
+ headers=["Model", "Total", "Daily", "Weekly", "Monthly", "Yearly"],
355
+ interactive=False,
356
+ wrap=True,
357
+ )
358
+ tokens_df = gr.DataFrame(
359
+ label="Token usage",
360
+ headers=["Model", "Total", "Daily", "Weekly", "Monthly", "Yearly"],
361
+ interactive=False,
362
+ wrap=True,
363
+ )
364
+ stats_btn.click(
365
+ fn=_fetch_stats,
366
+ inputs=[],
367
+ outputs=[access_df, tokens_df, stats_status],
368
+ api_name=False,
369
+ )
370
+ # Also load on first render for a friendlier first impression.
371
+ demo.load(
372
+ fn=_fetch_stats,
373
+ inputs=[],
374
+ outputs=[access_df, tokens_df, stats_status],
375
+ )
376
+
377
+ gr.HTML(_FOOTER)
378
+
379
+ # Bounded queue so the UI cannot saturate the underlying gate.
380
+ demo.queue(
381
+ max_size=settings.max_queue_depth,
382
+ default_concurrency_limit=settings.max_concurrent_conversions,
383
+ api_open=False,
384
+ )
385
+ return demo
pyproject.toml CHANGED
@@ -1,17 +1,97 @@
 
 
 
 
1
  [tool.poetry]
2
  name = "docsifer"
3
- version = "1.0.0"
4
- description = "Docsifer is a powerful tool for converting various data formats into Markdown for applications such as indexing, text analysis, and more. It supports PDF, PowerPoint, Word, Excel, Images, Audio, HTML, and other text-based formats, and leverages Large Language Models (LLMs) to enhance performance."
5
  authors = ["Hieu Lam <lamhieu.vk@gmail.com>"]
6
  readme = "README.md"
7
  homepage = "https://github.com/lh0x00/docsifer"
8
  repository = "https://github.com/lh0x00/docsifer"
9
  license = "MIT"
 
10
 
11
  [tool.poetry.dependencies]
12
  python = "^3.10"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
 
 
14
 
15
- [build-system]
16
- requires = ["poetry-core"]
17
- build-backend = "poetry.core.masonry.api"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["poetry-core>=1.6.0"]
3
+ build-backend = "poetry.core.masonry.api"
4
+
5
  [tool.poetry]
6
  name = "docsifer"
7
+ version = "1.1.0"
8
+ description = "Convert PDF, PPT, Word, Excel, images, audio, HTML, JSON, CSV, XML, ZIP and more to Markdown with optional LLM enhancement."
9
  authors = ["Hieu Lam <lamhieu.vk@gmail.com>"]
10
  readme = "README.md"
11
  homepage = "https://github.com/lh0x00/docsifer"
12
  repository = "https://github.com/lh0x00/docsifer"
13
  license = "MIT"
14
+ packages = [{ include = "docsifer" }]
15
 
16
  [tool.poetry.dependencies]
17
  python = "^3.10"
18
+ fastapi = ">=0.110,<1.0"
19
+ uvicorn = { version = ">=0.27,<1.0", extras = ["standard"] }
20
+ gunicorn = ">=21.2,<23.0"
21
+ pydantic = ">=2.6,<3.0"
22
+ pydantic-settings = ">=2.2,<3.0"
23
+ orjson = ">=3.10,<4.0"
24
+ python-multipart = ">=0.0.9"
25
+ httpx = ">=0.27,<1.0"
26
+ openai = ">=1.40,<2.0"
27
+ tiktoken = ">=0.7,<1.0"
28
+ markitdown = "0.0.1a3"
29
+ selectolax = ">=0.3.21"
30
+ python-magic = "0.4.27"
31
+ upstash-redis = ">=1.2,<2.0"
32
+ cachetools = ">=5.3,<6.0"
33
+ psutil = ">=5.9,<7.0"
34
+ gradio = { version = ">=4.40,<5.0", optional = true }
35
+ pandas = { version = ">=2.0,<3.0", optional = true }
36
+ requests = ">=2.31,<3.0"
37
 
38
+ [tool.poetry.extras]
39
+ ui = ["gradio", "pandas"]
40
 
41
+ [tool.poetry.group.dev.dependencies]
42
+ pytest = ">=8.0,<9.0"
43
+ pytest-asyncio = ">=0.23,<1.0"
44
+ ruff = ">=0.6,<1.0"
45
+ mypy = ">=1.10,<2.0"
46
+ types-requests = "*"
47
+ fakeredis = ">=2.23,<3.0"
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Tooling
51
+ # ---------------------------------------------------------------------------
52
+ [tool.ruff]
53
+ line-length = 100
54
+ target-version = "py310"
55
+ extend-exclude = ["poetry.lock"]
56
+
57
+ [tool.ruff.lint]
58
+ select = [
59
+ "E", "F", "W", # pycodestyle / pyflakes
60
+ "I", # isort
61
+ "B", # flake8-bugbear
62
+ "UP", # pyupgrade
63
+ "SIM", # simplify
64
+ "RET", # flake8-return
65
+ "C4", # comprehensions
66
+ "ASYNC", # async best practices
67
+ ]
68
+ ignore = [
69
+ "E501", # line length handled by formatter
70
+ "B008", # FastAPI Depends() in defaults is idiomatic
71
+ ]
72
+
73
+ [tool.ruff.lint.per-file-ignores]
74
+ "tests/**" = ["B011", "S101"]
75
+
76
+ [tool.ruff.format]
77
+ quote-style = "double"
78
+
79
+ [tool.mypy]
80
+ python_version = "3.10"
81
+ warn_unused_configs = true
82
+ warn_redundant_casts = true
83
+ warn_unused_ignores = true
84
+ disallow_untyped_defs = false
85
+ ignore_missing_imports = true
86
+ files = ["docsifer"]
87
+
88
+ [[tool.mypy.overrides]]
89
+ module = ["docsifer.core.*", "docsifer.analytics.*", "docsifer.safety.*"]
90
+ disallow_untyped_defs = true
91
+
92
+ [tool.pytest.ini_options]
93
+ asyncio_mode = "auto"
94
+ testpaths = ["tests"]
95
+ filterwarnings = [
96
+ "ignore::DeprecationWarning",
97
+ ]
requirements.txt CHANGED
@@ -1,15 +1,26 @@
1
- gradio
2
- fastapi
3
- uvicorn
4
- requests
5
- pydantic
6
- cachetools
7
- scuid
8
- markitdown @ git+https://github.com/microsoft/markitdown@519fe172
9
- upstash_redis==1.2.0
10
- openai==1.59.7
11
- pyquery==2.0.1
12
- tiktoken==0.8.0
13
- aiohttp==3.11.11
 
 
 
 
14
  python-magic==0.4.27
15
- youtube-transcript-api==0.6.3
 
 
 
 
 
 
 
 
1
+ # Production runtime requirements (Hugging Face Spaces / Docker).
2
+ # Keep in sync with [tool.poetry.dependencies] in pyproject.toml. For local
3
+ # development we recommend ``poetry install``; this file exists for build
4
+ # pipelines that don't speak Poetry.
5
+
6
+ fastapi>=0.110,<1.0
7
+ uvicorn[standard]>=0.27,<1.0
8
+ gunicorn>=21.2,<23.0
9
+ pydantic>=2.6,<3.0
10
+ pydantic-settings>=2.2,<3.0
11
+ orjson>=3.10,<4.0
12
+ python-multipart>=0.0.9
13
+ httpx>=0.27,<1.0
14
+ openai>=1.40,<2.0
15
+ tiktoken>=0.7,<1.0
16
+ markitdown==0.0.1a3
17
+ selectolax>=0.3.21
18
  python-magic==0.4.27
19
+ upstash-redis>=1.2,<2.0
20
+ cachetools>=5.3,<6.0
21
+ psutil>=5.9,<7.0
22
+ requests>=2.31,<3.0
23
+
24
+ # Optional UI extras
25
+ gradio>=4.40,<5.0
26
+ pandas>=2.0,<3.0