refactor(core): overhaul architecture for better performance, efficiency, and maintainability
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +52 -0
- .env.example +62 -0
- .github/workflows/ci.yml +63 -0
- .pre-commit-config.yaml +28 -0
- .pylintrc +0 -579
- ARCHITECTURE.md +140 -0
- CONTRIBUTING.md +42 -0
- Dockerfile +94 -32
- Makefile +45 -0
- README.md +96 -70
- app.py +5 -1
- docsifer/__init__.py +4 -499
- docsifer/analytics.py +0 -290
- docsifer/analytics/__init__.py +13 -0
- docsifer/analytics/periods.py +23 -0
- docsifer/analytics/service.py +193 -0
- docsifer/analytics/store.py +183 -0
- docsifer/api/__init__.py +1 -0
- docsifer/api/deps.py +56 -0
- docsifer/api/error_handlers.py +90 -0
- docsifer/api/middleware.py +78 -0
- docsifer/api/schemas.py +76 -0
- docsifer/api/v1/__init__.py +14 -0
- docsifer/api/v1/convert.py +213 -0
- docsifer/api/v1/health.py +42 -0
- docsifer/api/v1/stats.py +27 -0
- docsifer/config.py +199 -0
- docsifer/core/__init__.py +1 -0
- docsifer/core/html_cleaner.py +72 -0
- docsifer/core/llm_registry.py +104 -0
- docsifer/core/mime.py +79 -0
- docsifer/core/service.py +254 -0
- docsifer/core/tokenizer.py +52 -0
- docsifer/core/url_guard.py +81 -0
- docsifer/exceptions.py +107 -0
- docsifer/logging_config.py +120 -0
- docsifer/main.py +218 -0
- docsifer/router.py +0 -129
- docsifer/safety/__init__.py +17 -0
- docsifer/safety/circuit_breaker.py +66 -0
- docsifer/safety/conversion_gate.py +62 -0
- docsifer/safety/disk_cleanup.py +48 -0
- docsifer/safety/memory_watchdog.py +56 -0
- docsifer/safety/per_ip_limiter.py +65 -0
- docsifer/safety/resource_guard.py +67 -0
- docsifer/service.py +0 -227
- docsifer/ui/__init__.py +5 -0
- docsifer/ui/gradio_app.py +385 -0
- pyproject.toml +85 -5
- 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 |
-
#
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
#
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
EXPOSE 7860
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
pinned: false
|
|
|
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
| 12 |
|
| 13 |
-
|
| 14 |
|
| 15 |
-
|
| 16 |
|
| 17 |
-
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
|
| 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 |
-
|
| 41 |
|
| 42 |
-
##
|
| 43 |
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
-
### 2. Build and Run with Docker
|
| 50 |
-
Make sure Docker is installed and running on your machine.
|
| 51 |
```bash
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
```
|
| 55 |
|
| 56 |
-
|
| 57 |
|
| 58 |
-
##
|
| 59 |
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
-
|
| 63 |
-
- **`/v1/stats`**: Retrieve usage statistics, including access counts and token usage.
|
| 64 |
|
| 65 |
-
|
| 66 |
|
| 67 |
-
|
| 68 |
-
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
|
| 72 |
-
|
| 73 |
|
| 74 |
-
|
| 75 |
-
-
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
-
|
| 78 |
|
| 79 |
-
|
| 80 |
-
-
|
| 81 |
-
-
|
| 82 |
-
|
| 83 |
|
| 84 |
-
##
|
| 85 |
|
| 86 |
-
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
-
|
| 101 |
|
| 102 |
-
|
| 103 |
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
-
|
| 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 |
+
[](#)
|
| 22 |
+
[](#)
|
| 23 |
+
[](#)
|
| 24 |
+
[](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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
|
| 3 |
-
import
|
| 4 |
-
import logging
|
| 5 |
-
import tempfile
|
| 6 |
-
from typing import Tuple, Optional
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 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.
|
| 4 |
-
description = "
|
| 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 |
-
[
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
python-magic==0.4.27
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|