diff --git a/README.md b/README.md index 0964dd7..ac5b798 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,8 @@ The Standard pipeline above is **one of three tiers** available. Each has differ | Lynote.ai | Everyone — all tiers, zero setup | Visit lynote.ai| | n8n Workflow | No-code automation users | Import [`n8n/humanize_standard.json`](n8n/humanize_standard.json) | | Python Script | Developers | See below | +| n8n Workflow | No-code automation users | See below | +| Docker | Full-stack users (UI + API) | See below | ### Python @@ -118,6 +120,12 @@ cp config/config.example.toml config/config.toml python -m src.standard.pipeline --input "Your AI-generated text here" ``` +### Run with Docker + +```bash +docker compose up --build +``` + **DeepSeek (default):** ```toml @@ -161,6 +169,8 @@ Override the API endpoint with `base_url` in `[llm]`, or via `LLM_BASE_URL` / `L 2. Configure the LLM API key and URL in the HTTP Request nodes (defaults to DeepSeek; point at OpenRouter's `https://openrouter.ai/api/v1/chat/completions` to use OpenRouter) 3. Run — input text goes in, humanized text comes out +The web UI is available at `http://localhost:8000`. + --- ## Showcase — 5 Real Examples with Step-by-Step Outputs @@ -264,4 +274,3 @@ MIT License. See [LICENSE](LICENSE) for details. 🌐 Visit official website [lynote.ai](https://lynote.ai) to unlock full premium features. 💬 Have questions, feature requests or usage troubles? Feel free to start a discussion in [Discussions](https://github.com/lynote-ai/humanize-text/discussions). - diff --git a/config/config.example.toml b/config/config.example.toml index 1f92e42..b4ffa16 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -38,3 +38,11 @@ temperature = 1.3 # restructuring before reconstruction to the target language. # Other tested options: "de" (German), "ko" (Korean) intermediate_lang = "fi" +# Step 4 provider: "niutrans" | "libretranslate" +step4_provider = "niutrans" + +[providers.libretranslate] +# Self-hosted LibreTranslate endpoint (used when pipeline.step4_provider = "libretranslate") +base_url = "http://host.docker.internal:5000" +api_key = "" +timeout_sec = 60 diff --git a/docker-compose.yml b/docker-compose.yml index 2ba8880..f181000 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,41 @@ -version: '3.8' - services: ai-humanizer: build: context: . dockerfile: docker/Dockerfile + # Security: API routes are currently unauthenticated. + # Do not expose this port to the public internet. + # Deploy behind a reverse proxy and restrict access with firewall/private network. ports: - "8000:8000" volumes: - ./config:/app/config environment: - CONFIG_PATH=/app/config/config.toml + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/v1/health')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s restart: unless-stopped + extra_hosts: + - "host.docker.internal:host-gateway" + + # Optional Option B: hot-reload Vite dev server inside Docker. + # Start with: docker compose --profile frontend up -d + frontend: + image: oven/bun:latest + working_dir: /app/frontend + profiles: + - frontend + ports: + - "5173:5173" + volumes: + - ./frontend:/app/frontend + environment: + # Point the Vite proxy at the backend service in the Docker network. + - API_HOST=http://ai-humanizer:8000 + command: ["sh", "-c", "bun install && bun dev"] + depends_on: + - ai-humanizer diff --git a/docker/Dockerfile b/docker/Dockerfile index 0d5ade7..e1034f4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,12 +1,28 @@ -FROM python:3.11-slim +# Stage 1: Build Svelte frontend with Bun +FROM oven/bun:latest AS frontend + +WORKDIR /app/frontend + +COPY frontend/package.json ./ +RUN bun install + +COPY frontend/ ./ +RUN bun run build + +# Stage 2: Python application +FROM python:3.11-slim AS app WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt +RUN pip install "fastapi>=0.109.0" "uvicorn[standard]>=0.27.0" "pydantic>=2.0.0" COPY . . +# Copy the compiled frontend into /app/static so FastAPI can serve it +COPY --from=frontend /app/frontend/dist /app/static + EXPOSE 8000 -CMD ["uvicorn", "src.methodologies.humanizer:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..4517fda --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,39 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Lockfiles (choose one per environment) +# bun.lock +# yarn.lock +# package-lock.json +# pnpm-lock.yaml + +# Build output +dist/ +build/ +*.local + +# Vite / Vitest +.vite/ +coverage/ + +# Environment files +.env +.env.local +.env.*.local + +# Editor / OS +.DS_Store +Thumbs.db +*.swp +*.swo +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* diff --git a/frontend/bun.lock b/frontend/bun.lock new file mode 100644 index 0000000..abe1169 --- /dev/null +++ b/frontend/bun.lock @@ -0,0 +1,236 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "humanize-text-frontend", + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.1.2", + "svelte": "^4.2.19", + "vite": "^5.4.10", + "vitest": "^2.0.0", + }, + }, + }, + "packages": { + "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="], + + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@3.1.2", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0", "debug": "^4.3.4", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.10", "svelte-hmr": "^0.16.0", "vitefu": "^0.2.5" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.0" } }, "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA=="], + + "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@2.1.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.0" } }, "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="], + + "@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="], + + "@vitest/runner": ["@vitest/runner@2.1.9", "", { "dependencies": { "@vitest/utils": "2.1.9", "pathe": "^1.1.2" } }, "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g=="], + + "@vitest/snapshot": ["@vitest/snapshot@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "magic-string": "^0.30.12", "pathe": "^1.1.2" } }, "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ=="], + + "@vitest/spy": ["@vitest/spy@2.1.9", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ=="], + + "@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="], + + "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + + "code-red": ["code-red@1.0.4", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@types/estree": "^1.0.1", "acorn": "^8.10.0", "estree-walker": "^3.0.3", "periscopic": "^3.1.0" } }, "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw=="], + + "css-tree": ["css-tree@2.3.1", "", { "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" } }, "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mdn-data": ["mdn-data@2.0.30", "", {}, "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + + "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], + + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + + "periscopic": ["periscopic@3.1.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^3.0.0", "is-reference": "^3.0.0" } }, "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], + + "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "svelte": ["svelte@4.2.20", "", { "dependencies": { "@ampproject/remapping": "^2.2.1", "@jridgewell/sourcemap-codec": "^1.4.15", "@jridgewell/trace-mapping": "^0.3.18", "@types/estree": "^1.0.1", "acorn": "^8.9.0", "aria-query": "^5.3.0", "axobject-query": "^4.0.0", "code-red": "^1.0.3", "css-tree": "^2.3.1", "estree-walker": "^3.0.3", "is-reference": "^3.0.1", "locate-character": "^3.0.0", "magic-string": "^0.30.4", "periscopic": "^3.1.0" } }, "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q=="], + + "svelte-hmr": ["svelte-hmr@0.16.0", "", { "peerDependencies": { "svelte": "^3.19.0 || ^4.0.0" } }, "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + + "tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="], + + "tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="], + + "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], + + "vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="], + + "vitefu": ["vitefu@0.2.5", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["vite"] }, "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q=="], + + "vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..94e2c1b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Humanize Text + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e6be6aa --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "humanize-text-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest", + "test:run": "vitest run" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.1.2", + "svelte": "^4.2.19", + "vite": "^5.4.10", + "vitest": "^2.0.0" + } +} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte new file mode 100644 index 0000000..7f163ee --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,464 @@ + + + + +
+ + +
+
+ AI Text Humanizer +

+ Make AI-written text sound + genuinely human +

+

+ Paste your text, choose a method, and let the pipeline rewrite it with + natural rhythm, varied vocabulary, and cross-engine translation. +

+
+ +
+
+
+ +
+ + diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js new file mode 100644 index 0000000..76bd558 --- /dev/null +++ b/frontend/src/lib/api.js @@ -0,0 +1,91 @@ +/** + * Hardened API helper for the Humanize Text frontend. + * + * Builds a JSON request body, checks the response status and Content-Type, + * and only parses JSON for actual JSON responses. Text/HTML error pages + * are read as text and surfaced as a friendly error message. + */ + +const DEFAULT_ENDPOINT = '/humanize'; + +/** + * Build the request payload object from the form values. + * + * The payload is a plain object and is stringified with the standard + * `JSON.stringify()` right before sending, so quotes, newlines, and + * Unicode are handled automatically. + * + * @param {Object} params + * @param {string} params.text + * @param {string} params.method + * @param {string} params.language + * @param {string} [params.tier='standard'] + * @returns {{ text: string, method: string, language: string, tier: string }} + */ +export function buildPayload({ text, method, language, tier = 'standard' }) { + return { + text: text.trim(), + method, + language, + tier, + }; +} + +/** + * POST the humanize request to the FastAPI backend. + * + * @param {Object} params + * @param {string} params.text + * @param {string} params.method + * @param {string} params.language + * @param {string} [params.tier='standard'] + * @returns {Promise} Parsed JSON response. + * @throws {Error} On network failure, non-OK status, or non-JSON response. + */ +export async function humanizeText({ text, method, language, tier = 'standard' }) { + const payload = buildPayload({ text, method, language, tier }); + + // Allow overriding the API endpoint in dev/test environments. + const endpoint = import.meta.env?.VITE_API_URL ?? DEFAULT_ENDPOINT; + + console.log('[humanize] sending payload:', payload); + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(payload), + }); + + const contentType = response.headers.get('content-type') || ''; + const isJson = contentType.toLowerCase().includes('application/json'); + + console.log('[humanize] response status:', response.status, 'content-type:', contentType || '(none)'); + + if (!response.ok) { + if (isJson) { + const data = await response.json(); + console.error('[humanize] JSON error response:', data); + const message = + data.detail || data.message || data.error || `Request failed (${response.status})`; + throw new Error(message); + } + + const rawText = await response.text(); + console.error('[humanize] non-JSON error response:', rawText.slice(0, 1000)); + throw new Error( + `Server returned ${response.status} (${response.statusText}). ` + + 'The backend may have crashed or returned an HTML error page.' + ); + } + + if (!isJson) { + const rawText = await response.text(); + console.error('[humanize] unexpected non-JSON success response:', rawText.slice(0, 1000)); + throw new Error('Unexpected response format from server. Expected JSON.'); + } + + return response.json(); +} diff --git a/frontend/src/lib/api.test.js b/frontend/src/lib/api.test.js new file mode 100644 index 0000000..3c32fad --- /dev/null +++ b/frontend/src/lib/api.test.js @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' +import { buildPayload, humanizeText } from './api.js' + +const defaultParams = { + text: 'hello', + method: 'translation_chain', + language: 'en', + tier: 'standard', +} + +describe('buildPayload', () => { + it('creates the expected payload for plain text', () => { + const payload = buildPayload(defaultParams) + expect(payload).toEqual({ + text: 'hello', + method: 'translation_chain', + language: 'en', + tier: 'standard', + }) + expect(JSON.parse(JSON.stringify(payload))).toEqual(payload) + }) + + it('serializes text containing quotes correctly via JSON.stringify', () => { + const payload = buildPayload({ + ...defaultParams, + text: 'She said "hello" and then left.', + }) + const serialized = JSON.stringify(payload) + expect(serialized).toBe( + '{"text":"She said \\"hello\\" and then left.","method":"translation_chain","language":"en","tier":"standard"}' + ) + expect(JSON.parse(serialized).text).toBe('She said "hello" and then left.') + }) + + it('serializes text containing newlines correctly via JSON.stringify', () => { + const payload = buildPayload({ + ...defaultParams, + text: 'Line one\nLine two\r\nLine three', + }) + const serialized = JSON.stringify(payload) + expect(serialized).toContain('\\n') + const parsed = JSON.parse(serialized) + expect(parsed.text).toBe('Line one\nLine two\r\nLine three') + }) + + it('serializes text containing Unicode and emoji correctly', () => { + const text = 'Hello world 🌍 café' + const payload = buildPayload({ ...defaultParams, text }) + const serialized = JSON.stringify(payload) + const parsed = JSON.parse(serialized) + expect(parsed.text).toBe(text) + }) + + it('trims whitespace from the text field', () => { + const payload = buildPayload({ + ...defaultParams, + text: ' spaced out ', + }) + expect(payload.text).toBe('spaced out') + }) +}) + +describe('humanizeText response handling', () => { + let fetchSpy + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + function mockResponse({ ok = true, status = 200, statusText = 'OK', contentType = 'application/json', bodyText = '', json = null }) { + return { + ok, + status, + statusText, + headers: { + get: (name) => (name.toLowerCase() === 'content-type' ? contentType : null), + }, + text: async () => bodyText, + json: async () => (json !== null ? json : JSON.parse(bodyText)), + } + } + + it('resolves with parsed JSON on a successful JSON response', async () => { + const response = mockResponse({ + bodyText: JSON.stringify({ + result: 'humanized hello', + method: 'translation_chain', + processing_time_ms: 42, + }), + }) + fetchSpy.mockResolvedValue(response) + + const data = await humanizeText(defaultParams) + expect(data.result).toBe('humanized hello') + expect(data.processing_time_ms).toBe(42) + expect(fetchSpy).toHaveBeenCalledOnce() + const [url, options] = fetchSpy.mock.calls[0] + expect(url).toBe('/humanize') + expect(options.method).toBe('POST') + expect(options.headers['Content-Type']).toBe('application/json') + expect(JSON.parse(options.body).text).toBe('hello') + }) + + it('throws a friendly error for an HTML 500 response', async () => { + fetchSpy.mockResolvedValue( + mockResponse({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + contentType: 'text/html; charset=utf-8', + bodyText: 'Internal Server Error', + }) + ) + + await expect(humanizeText(defaultParams)).rejects.toThrow( + 'Server returned 500 (Internal Server Error)' + ) + }) + + it('throws a friendly error for a non-JSON success response', async () => { + fetchSpy.mockResolvedValue( + mockResponse({ + ok: true, + status: 200, + contentType: 'text/plain', + bodyText: 'unexpected plaintext', + }) + ) + + await expect(humanizeText(defaultParams)).rejects.toThrow( + 'Unexpected response format from server' + ) + }) + + it('uses the detail/message/error field from JSON errors', async () => { + fetchSpy.mockResolvedValue( + mockResponse({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + bodyText: JSON.stringify({ detail: 'Missing required field' }), + }) + ) + + await expect(humanizeText(defaultParams)).rejects.toThrow('Missing required field') + }) + + it('re-throws network errors without crashing', async () => { + fetchSpy.mockRejectedValue(new TypeError('Failed to fetch')) + + await expect(humanizeText(defaultParams)).rejects.toThrow('Failed to fetch') + }) +}) diff --git a/frontend/src/lib/validation.js b/frontend/src/lib/validation.js new file mode 100644 index 0000000..9cd41bc --- /dev/null +++ b/frontend/src/lib/validation.js @@ -0,0 +1,25 @@ +/** + * Pure input-validation utilities for the humanization form. + * + * Keeping this logic in plain JS makes it trivial to unit-test without + * needing a browser or Svelte component harness. + */ + +export const DEFAULT_MAX_CHARS = 5000; + +export function validateHumanizeInput(text, maxChars = DEFAULT_MAX_CHARS) { + const trimmed = text.trim(); + + if (!trimmed) { + return { ok: false, message: 'Please enter some text to humanize.' }; + } + + if (trimmed.length > maxChars) { + return { + ok: false, + message: `Text is too long. Please keep it under ${maxChars.toLocaleString()} characters.`, + }; + } + + return { ok: true, trimmed }; +} diff --git a/frontend/src/lib/validation.test.js b/frontend/src/lib/validation.test.js new file mode 100644 index 0000000..f03e3b2 --- /dev/null +++ b/frontend/src/lib/validation.test.js @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest' +import { validateHumanizeInput } from './validation.js' + +describe('validateHumanizeInput', () => { + it('accepts plain text and returns the trimmed value', () => { + const result = validateHumanizeInput(' hello world ') + expect(result).toEqual({ + ok: true, + trimmed: 'hello world', + }) + }) + + it('rejects empty input', () => { + const result = validateHumanizeInput('') + expect(result.ok).toBe(false) + expect(result.message).toMatch(/enter some text/i) + }) + + it('rejects whitespace-only input', () => { + const result = validateHumanizeInput(' \n\t ') + expect(result.ok).toBe(false) + expect(result.message).toMatch(/enter some text/i) + }) + + it('rejects input that exceeds the character limit', () => { + const long = 'a'.repeat(5001) + const result = validateHumanizeInput(long) + expect(result.ok).toBe(false) + expect(result.message).toMatch(/too long/i) + }) + + it('respects a custom max length', () => { + const result = validateHumanizeInput('abc', 2) + expect(result.ok).toBe(false) + expect(result.message).toMatch(/too long/i) + }) +}) diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..5bb8247 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,8 @@ +import App from './App.svelte' +import './app.css' + +const app = new App({ + target: document.getElementById('app'), +}) + +export default app diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..d4d751b --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,28 @@ +import { defineConfig } from 'vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' + +// In Docker dev mode the backend container is named `ai-humanizer`, so the +// proxy target can be overridden via API_HOST. Locally it defaults to localhost. +const API_HOST = process.env.API_HOST ?? 'http://localhost:8000' + +// https://vitejs.dev/config/ +export default defineConfig({ + base: '/static/', + plugins: [svelte()], + server: { + host: '0.0.0.0', + port: 5173, + proxy: { + // Proxy the FastAPI endpoints used by the app. + '/humanize': API_HOST, + '/methods': API_HOST, + '/health': API_HOST, + // Optional /api alias for future endpoints or local dev conventions. + '/api': { + target: API_HOST, + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ''), + }, + }, + }, +}) diff --git a/frontend/vitest.config.js b/frontend/vitest.config.js new file mode 100644 index 0000000..f1881c1 --- /dev/null +++ b/frontend/vitest.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + }, +}) diff --git a/setup.py b/setup.py index dd8924e..e55f2fb 100644 --- a/setup.py +++ b/setup.py @@ -25,6 +25,11 @@ "deep-translator>=1.11.0", ], extras_require={ + "api": [ + "fastapi>=0.109.0", + "uvicorn[standard]>=0.27.0", + "pydantic>=2.0.0" + ], "litellm": [ "litellm>=1.80.0,<1.87.0", ], diff --git a/src/api.py b/src/api.py new file mode 100644 index 0000000..072481c --- /dev/null +++ b/src/api.py @@ -0,0 +1,18 @@ +"""Unified FastAPI entry point combining v1.0 legacy endpoints and v1.5 Standard API. + +The v1.5 router is mounted under `/v1`; the v1.0 dispatcher and its static +frontend are mounted at root so the same Uvicorn process serves both. +""" + +from fastapi import FastAPI + +from src.methodologies.humanizer import app as legacy_app +from src.standard.api import router + +app = FastAPI(title="Humanize-Text API") + +# v1.5 Standard Pipeline routes (checked before the legacy mount) +app.include_router(router, prefix="/v1") + +# v1.0 dispatcher + Svelte frontend static files +app.mount("/", legacy_app, name="legacy") diff --git a/src/methodologies/humanizer.py b/src/methodologies/humanizer.py index d8b2b2e..6abc56b 100644 --- a/src/methodologies/humanizer.py +++ b/src/methodologies/humanizer.py @@ -7,6 +7,7 @@ from src.standard import run_standard_pipeline """ +import os import time from dataclasses import dataclass @@ -14,6 +15,8 @@ import toml import uvicorn from fastapi import FastAPI +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from .translation_chain import TranslationChainProcessor @@ -72,10 +75,9 @@ class HumanizeRequest(BaseModel): @app.post("/humanize") def api_humanize(req: HumanizeRequest): - import os config_path = os.environ.get("CONFIG_PATH", "config/config.toml") h = Humanizer(config_path=config_path) - result = h.process(req.text, method=req.method, tier=req.tier) + result = h.process(req.text, method=req.method, tier=req.tier, language=req.language) return { "result": result.text, "method": result.method_used, @@ -93,6 +95,18 @@ def api_health(): return {"status": "ok"} +# --- Static frontend (Svelte + Vite build output) --- + +STATIC_DIR = "/app/static" + +if os.path.isdir(STATIC_DIR): + app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + @app.get("/{full_path:path}") + def serve_spa(full_path: str): + return FileResponse(f"{STATIC_DIR}/index.html") + + # --- CLI --- @click.command() @@ -108,7 +122,6 @@ def main(input_text, method, output, config, tier, language, serve): uvicorn.run(app, host="0.0.0.0", port=8000) return - import os if os.path.isfile(input_text): with open(input_text, "r", encoding="utf-8") as f: input_text = f.read() diff --git a/src/methodologies/translation_chain.py b/src/methodologies/translation_chain.py index b6330bf..e745c92 100644 --- a/src/methodologies/translation_chain.py +++ b/src/methodologies/translation_chain.py @@ -17,21 +17,41 @@ class TranslationChainProcessor: def __init__(self, config: dict): self.config = config.get("translation_chain", {}) - self.chain = self.config.get("chain", ["zh-CN", "ja", "fi"]) + # Use locale-aware codes so both Google and MyMemory accept them. + self.chain = self.config.get("chain", ["zh-CN", "ja-JP", "fi"]) self.engines = self.config.get("engines", ["google", "mymemory"]) + @staticmethod + def _normalize_language(engine: str, code: str) -> str: + """Map user/language codes to the format each engine expects.""" + low = code.lower() + if engine == "mymemory": + if low.startswith("ja"): + return "ja-JP" + if low == "en": + return "en-US" + return code + elif engine == "google": + # Google only supports a few locale tags; strip regions for the rest. + if low in {"zh-cn", "zh-tw", "mni-mtei"}: + return code + return code.split("-")[0] + return code + def _get_translator(self, engine: str, source: str, target: str): + source = self._normalize_language(engine, source) + target = self._normalize_language(engine, target) if engine == "google": return GoogleTranslator(source=source, target=target) elif engine == "mymemory": return MyMemoryTranslator(source=source, target=target) raise ValueError(f"Unsupported engine: {engine}") - def process(self, text: str, tier: str = "standard", **kwargs) -> str: + def process(self, text: str, tier: str = "standard", language: str = "en", **kwargs) -> str: max_hops = self.TIERS.get(tier, 3) languages = self.chain[:max_hops - 1] current_text = text - prev_lang = "en" + prev_lang = language for i, lang in enumerate(languages): engine = self.engines[i % len(self.engines)] @@ -39,7 +59,7 @@ def process(self, text: str, tier: str = "standard", **kwargs) -> str: current_text = translator.translate(current_text) prev_lang = lang - final_translator = self._get_translator(self.engines[0], prev_lang, "en") + final_translator = self._get_translator(self.engines[0], prev_lang, language) current_text = final_translator.translate(current_text) return current_text diff --git a/src/standard/__init__.py b/src/standard/__init__.py index 7787a5d..1509856 100644 --- a/src/standard/__init__.py +++ b/src/standard/__init__.py @@ -16,11 +16,12 @@ from .pipeline import run_standard_pipeline from .llm_rewriter import deepseek_rewrite -from .translators import google_translate, niutrans_translate +from .translators import google_translate, libretranslate_translate, niutrans_translate __all__ = [ "run_standard_pipeline", "deepseek_rewrite", "google_translate", + "libretranslate_translate", "niutrans_translate", ] diff --git a/src/standard/api.py b/src/standard/api.py new file mode 100644 index 0000000..c52731a --- /dev/null +++ b/src/standard/api.py @@ -0,0 +1,188 @@ +"""v1.5 Standard Pipeline REST API. + +Exposes the production Standard Pipeline under `/v1` so the frontend can use it +as the primary path while the legacy `/humanize` endpoints remain available. +""" + +import os +import time +from typing import Optional + +import toml +from fastapi import APIRouter, HTTPException, status +from pydantic import BaseModel, Field + +from src.methodologies.humanizer import Humanizer +from src.standard.pipeline import run_standard_pipeline + +router = APIRouter() + + +# --------------------------------------------------------------------------- +# Configuration helpers +# --------------------------------------------------------------------------- + + +def _get_config_path() -> str: + return os.environ.get("CONFIG_PATH", "config/config.toml") + + +def _load_config() -> dict: + path = _get_config_path() + if not os.path.exists(path): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Config file not found: {path}", + ) + with open(path, "r", encoding="utf-8") as f: + return toml.load(f) + + +# --------------------------------------------------------------------------- +# Language normalization +# --------------------------------------------------------------------------- + + +def normalize_target_lang(target_lang: str) -> str: + """Map locale-style codes to the base language codes the pipeline expects. + + Examples: + en-US -> en, fr-FR -> fr, zh-CN -> zh, ja-JP -> ja. + """ + lower = target_lang.lower() + if lower.startswith("en-"): + return "en" + if lower.startswith("fr-"): + return "fr" + if lower.startswith("zh-"): + return "zh" + if lower.startswith("ja-"): + return "ja" + if lower.startswith("ko-"): + return "ko" + if lower.startswith("de-"): + return "de" + if lower.startswith("es-"): + return "es" + if lower.startswith("pt-"): + return "pt" + if lower.startswith("ru-"): + return "ru" + if lower.startswith("ar-"): + return "ar" + if lower.startswith("it-"): + return "it" + if lower.startswith("nl-"): + return "nl" + return target_lang.split("-")[0] + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + + +class StandardRequest(BaseModel): + text: str = Field(..., min_length=1, description="Text to humanize") + target_lang: str = Field(default="en", description="Target language code") + + +class StandardResponse(BaseModel): + result: str + steps: list + processing_time_ms: int + target_lang: dict + pipeline: dict + + +class LegacyRequest(BaseModel): + text: str + method: str = "translation_chain" + language: str = "en" + tier: str = "standard" + + +class LegacyResponse(BaseModel): + result: str + method: str + processing_time_ms: int + + +class HealthResponse(BaseModel): + status: str + version: str + + +class ConfigResponse(BaseModel): + intermediate_lang: str + step4_provider: str + provider: str + model: str + base_url: str + libretranslate_base_url: str + temperature: float + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get("/health", response_model=HealthResponse) +def v1_health(): + return {"status": "ok", "version": "1.5"} + + +@router.get("/config", response_model=ConfigResponse) +def v1_config(): + """Return non-sensitive pipeline configuration. API keys are never exposed.""" + config = _load_config() + llm = config.get("llm", {}) + pipeline = config.get("pipeline", {}) + libre_cfg = config.get("providers", {}).get("libretranslate", {}) + + return { + "intermediate_lang": pipeline.get("intermediate_lang", "fi"), + "step4_provider": pipeline.get("step4_provider", "niutrans"), + "provider": llm.get("provider", "deepseek"), + "model": llm.get("model") or pipeline.get("model", "deepseek-chat"), + "base_url": llm.get("base_url", ""), + "libretranslate_base_url": libre_cfg.get("base_url", ""), + "temperature": llm.get("temperature", pipeline.get("temperature", 1.3)), + } + + +@router.post("/humanize/standard", response_model=StandardResponse) +def v1_humanize_standard(req: StandardRequest): + """Run the v1.5 Standard Pipeline (two LLM rewrites + two translations).""" + config = _load_config() + normalized = normalize_target_lang(req.target_lang) + + result = run_standard_pipeline(req.text, config, target_lang=normalized) + + return { + "result": result["result"], + "steps": result["steps"], + "processing_time_ms": result["processing_time_ms"], + "target_lang": { + "original": req.target_lang, + "normalized": normalized, + }, + "pipeline": { + "intermediate_lang": config.get("pipeline", {}).get("intermediate_lang", "fi"), + }, + } + + +@router.post("/humanize", response_model=LegacyResponse) +def v1_humanize_legacy(req: LegacyRequest): + """Legacy bridge: route the v1.0 request format to the v1.0 dispatcher.""" + h = Humanizer(config_path=_get_config_path()) + result = h.process( + req.text, method=req.method, tier=req.tier, language=req.language + ) + return { + "result": result.text, + "method": result.method_used, + "processing_time_ms": int(result.processing_time * 1000), + } diff --git a/src/standard/pipeline.py b/src/standard/pipeline.py index 487e8f5..442aa9f 100644 --- a/src/standard/pipeline.py +++ b/src/standard/pipeline.py @@ -17,7 +17,7 @@ import toml from .llm_client import resolve_llm_config -from .translators import google_translate, niutrans_translate +from .translators import google_translate, libretranslate_translate, niutrans_translate from .llm_rewriter import llm_rewrite @@ -87,15 +87,32 @@ def run_standard_pipeline(text: str, config: dict, target_lang: str = "en") -> d "output": step3, "length": len(step3), }) - # Step 4: Niutrans — intermediate language → target (second translation hop) - step4 = niutrans_translate( - step3, - source=intermediate_lang, - target=_lang_code_to_niutrans(target_lang), - api_key=niutrans_key, - ) + # Step 4: configured provider (Niutrans or LibreTranslate) — intermediate language → target (second translation hop) + pipeline_cfg = config.get("pipeline", {}) + step4_provider = pipeline_cfg.get("step4_provider", "niutrans") + + if step4_provider == "libretranslate": + libre_cfg = config.get("providers", {}).get("libretranslate", {}) + step4 = libretranslate_translate( + step3, + source=intermediate_lang, + target=target_lang, + base_url=libre_cfg.get("base_url", "http://localhost:5000"), + api_key=libre_cfg.get("api_key", ""), + timeout=libre_cfg.get("timeout_sec", 60), + ) + step4_engine = "LibreTranslate" + else: + step4 = niutrans_translate( + step3, + source=intermediate_lang, + target=_lang_code_to_niutrans(target_lang), + api_key=niutrans_key, + ) + step4_engine = "Niutrans" + steps.append({ - "step": 4, "engine": "Niutrans", + "step": 4, "engine": step4_engine, "direction": f"{intermediate_lang.upper()} → {target_lang.upper()} (二轮翻译)", "output": step4, "length": len(step4), }) diff --git a/src/standard/translators.py b/src/standard/translators.py index 8d830fd..11cfad1 100644 --- a/src/standard/translators.py +++ b/src/standard/translators.py @@ -58,6 +58,46 @@ def niutrans_translate(text: str, source: str, target: str, api_key: str) -> str raise RuntimeError(f"Unexpected Niutrans response: {data}") +def libretranslate_translate( + text: str, + source: str, + target: str, + base_url: str, + api_key: str = "", + timeout: int = 60, +) -> str: + """Translate text using a self-hosted LibreTranslate instance. + + Args: + text: Text to translate. + source: Source language code. + target: Target language code. + base_url: LibreTranslate base URL (e.g. http://localhost:5000). + api_key: Optional LibreTranslate API key. + timeout: Request timeout in seconds. + + Returns: + Translated text. + """ + url = f"{base_url.rstrip('/')}/translate" + payload = { + "q": text, + "source": source, + "target": target, + "format": "text", + } + if api_key: + payload["api_key"] = api_key + + response = httpx.post(url, json=payload, timeout=timeout) + response.raise_for_status() + data = response.json() + + if isinstance(data, dict) and "translatedText" in data: + return data["translatedText"] + raise RuntimeError(f"Unexpected LibreTranslate response: {data}") + + def _split_text(text: str, max_len: int = 4500) -> list[str]: """Split text into chunks at sentence boundaries.""" import re diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 651f45a..4c6fb1e 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -29,12 +29,14 @@ def test_standard_package_public_surface(): run_standard_pipeline, deepseek_rewrite, google_translate, + libretranslate_translate, niutrans_translate, ) - # All four must be callable + # All five must be callable assert callable(run_standard_pipeline) assert callable(deepseek_rewrite) assert callable(google_translate) + assert callable(libretranslate_translate) assert callable(niutrans_translate) @@ -79,6 +81,62 @@ def test_lang_code_mapping_passthrough_for_unknown(): assert _lang_code_to_niutrans("xx") == "xx" +def test_libretranslate_translate_returns_translated_text(monkeypatch): + from src.standard.translators import libretranslate_translate + + class FakeResponse: + def raise_for_status(self): + pass + + def json(self): + return {"translatedText": "Bonjour"} + + def fake_post(url, json, timeout): + assert url == "http://localhost:5000/translate" + assert json["q"] == "Hello" + assert json["source"] == "en" + assert json["target"] == "fr" + assert json["format"] == "text" + assert "api_key" not in json + return FakeResponse() + + monkeypatch.setattr("src.standard.translators.httpx.post", fake_post) + assert libretranslate_translate("Hello", "en", "fr", "http://localhost:5000") == "Bonjour" + + +def test_libretranslate_translate_sends_api_key_when_provided(monkeypatch): + from src.standard.translators import libretranslate_translate + + class FakeResponse: + def raise_for_status(self): + pass + + def json(self): + return {"translatedText": "Hola"} + + def fake_post(url, json, timeout): + assert json["api_key"] == "secret-key" + return FakeResponse() + + monkeypatch.setattr("src.standard.translators.httpx.post", fake_post) + assert libretranslate_translate("Hello", "en", "es", "http://libre.example.com", api_key="secret-key") == "Hola" + + +def test_libretranslate_translate_raises_on_missing_translated_text(monkeypatch): + from src.standard.translators import libretranslate_translate + + class FakeResponse: + def raise_for_status(self): + pass + + def json(self): + return {"error": "unsupported language pair"} + + monkeypatch.setattr("src.standard.translators.httpx.post", lambda *args, **kwargs: FakeResponse()) + with pytest.raises(RuntimeError, match="Unexpected LibreTranslate response"): + libretranslate_translate("Hello", "en", "xx", "http://localhost:5000") + + def test_split_text_respects_max_length(): from src.standard.translators import _split_text sentence = "This is a test sentence. " * 200 # ~5000 chars diff --git a/tests/test_standard_api.py b/tests/test_standard_api.py new file mode 100644 index 0000000..e4e48f2 --- /dev/null +++ b/tests/test_standard_api.py @@ -0,0 +1,187 @@ +"""Tests for the v1.5 Standard Pipeline API (src/api.py + src/standard/api.py). + +All external API calls are mocked so no API keys or network access are needed. +""" + +from pathlib import Path +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from src.api import app + + +@pytest.fixture +def client(monkeypatch, tmp_path: Path) -> TestClient: + """Provide a test client configured with a temporary, safe config.toml.""" + config = tmp_path / "config.toml" + config.write_text( + """ +[general] +target_language = "en" + +[api_keys] +deepseek_api_key = "sk-secret" +openrouter_api_key = "sk-or-secret" +niutrans_api_key = "secret-niutrans" + +[llm] +provider = "deepseek" +model = "deepseek-chat" +base_url = "https://api.deepseek.com" +temperature = 1.3 +http_referer = "https://example.com" +app_title = "Test App" + +[pipeline] +model = "fallback-model" +temperature = 1.1 +intermediate_lang = "fi" +step4_provider = "niutrans" + +[providers.libretranslate] +base_url = "http://libre.example.com:5000" +api_key = "secret-libre" +timeout_sec = 30 +""", + encoding="utf-8", + ) + monkeypatch.setenv("CONFIG_PATH", str(config)) + return TestClient(app) + + +def test_v1_health(client: TestClient) -> None: + response = client.get("/v1/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok", "version": "1.5"} + + +def test_v1_config_redacts_api_keys(client: TestClient) -> None: + response = client.get("/v1/config") + assert response.status_code == 200 + + data = response.json() + assert { "intermediate_lang", "step4_provider", "provider", "model", "base_url", "libretranslate_base_url", "temperature", }.issubset(data.keys()) + + # No API keys or secrets should be exposed anywhere in the response. + response_text = response.text + assert "secret" not in response_text.lower() + assert "api_key" not in response_text.lower() + assert "sk-" not in response_text + + assert data["provider"] == "deepseek" + assert data["model"] == "deepseek-chat" + assert data["base_url"] == "https://api.deepseek.com" + assert data["intermediate_lang"] == "fi" + assert data["step4_provider"] == "niutrans" + assert data["libretranslate_base_url"] == "http://libre.example.com:5000" + assert data["temperature"] == 1.3 + + +def test_v1_humanize_standard_response_shape(client: TestClient, monkeypatch) -> None: + """POST /v1/humanize/standard returns the expected shape and timing.""" + import src.standard.api as api_module + + def fake_run(text: str, config: Any, target_lang: str) -> dict: + assert text == "hello world" + assert target_lang == "en" + return { + "result": "Hello, world!", + "steps": [ + { + "step": 1, + "engine": "DeepSeek", + "direction": "Input → Chinese", + "output": "你好世界", + "length": 4, + } + ], + "processing_time_ms": 1200, + } + + monkeypatch.setattr(api_module, "run_standard_pipeline", fake_run) + + response = client.post("/v1/humanize/standard", json={"text": "hello world", "target_lang": "en"}) + assert response.status_code == 200 + + data = response.json() + assert data["result"] == "Hello, world!" + assert data["processing_time_ms"] == 1200 + assert len(data["steps"]) == 1 + assert data["target_lang"] == {"original": "en", "normalized": "en"} + assert data["pipeline"]["intermediate_lang"] == "fi" + + +def test_v1_humanize_standard_language_normalization(client: TestClient, monkeypatch) -> None: + """Locale-style codes like en-US are normalized to base codes before running the pipeline.""" + import src.standard.api as api_module + + captured: dict[str, Any] = {} + + def fake_run(text: str, config: Any, target_lang: str) -> dict: + captured["target_lang"] = target_lang + return { + "result": "Hello!", + "steps": [], + "processing_time_ms": 500, + } + + monkeypatch.setattr(api_module, "run_standard_pipeline", fake_run) + + response = client.post("/v1/humanize/standard", json={"text": "hello", "target_lang": "en-US"}) + assert response.status_code == 200 + + assert captured["target_lang"] == "en" + data = response.json() + assert data["target_lang"] == {"original": "en-US", "normalized": "en"} + + +def test_v1_humanize_standard_rejects_empty_text(client: TestClient) -> None: + response = client.post("/v1/humanize/standard", json={"text": "", "target_lang": "en"}) + assert response.status_code == 422 + + +def test_v1_humanize_legacy_bridge(client: TestClient, monkeypatch) -> None: + """POST /v1/humanize accepts the legacy body format and returns the legacy shape.""" + from src.methodologies import humanizer + + class FakeHumanizeResult: + text = "rewritten!" + method_used = "translation_chain" + processing_time = 0.5 + + def fake_process(self, text: str, method: str = None, **kwargs) -> Any: + assert text == "hello" + assert method == "translation_chain" + assert kwargs["tier"] == "standard" + assert kwargs["language"] == "en-US" + return FakeHumanizeResult() + + monkeypatch.setattr(humanizer.Humanizer, "process", fake_process) + + response = client.post( + "/v1/humanize", + json={"text": "hello", "method": "translation_chain", "language": "en-US", "tier": "standard"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["result"] == "rewritten!" + assert data["method"] == "translation_chain" + assert data["processing_time_ms"] == 500 + + +# The legacy v1.0 endpoints are mounted at the root and should not be broken. +# We do not assert on their behavior here (which would require API keys), but we +# verify the routes are reachable and the app structure is intact. + +def test_legacy_methods_endpoint_is_reachable(client: TestClient) -> None: + response = client.get("/methods") + assert response.status_code == 200 + assert "methods" in response.json() + + +def test_legacy_health_endpoint_is_reachable(client: TestClient) -> None: + response = client.get("/health") + assert response.status_code == 200 + assert response.json()["status"] == "ok" diff --git a/tests/test_standard_pipeline.py b/tests/test_standard_pipeline.py new file mode 100644 index 0000000..7b40745 --- /dev/null +++ b/tests/test_standard_pipeline.py @@ -0,0 +1,112 @@ +"""Tests for Standard Pipeline Step 4 provider switching. + +All external calls are mocked; no API keys or network access needed. +""" + +from typing import Any + +import pytest + +import src.standard.pipeline as pipeline_module +from src.standard.pipeline import run_standard_pipeline + + +@pytest.fixture +def base_config() -> dict[str, Any]: + return { + "api_keys": { + "deepseek_api_key": "sk-secret", + "niutrans_api_key": "secret-niutrans", + }, + "llm": { + "provider": "deepseek", + "model": "deepseek-chat", + "base_url": "https://api.deepseek.com", + "temperature": 1.3, + }, + "pipeline": {"intermediate_lang": "fi", "step4_provider": "niutrans"}, + "providers": { + "libretranslate": { + "base_url": "http://libre.example.com:5000", + "api_key": "secret-libre", + "timeout_sec": 30, + } + }, + } + + +@pytest.fixture +def stub_pipeline(monkeypatch): + """Stub LLM steps, Google Translate, and both Step 4 translators.""" + monkeypatch.setattr( + pipeline_module, + "llm_rewrite", + lambda **kwargs: f"rewritten({kwargs['target_language']}): {kwargs['text']}", + ) + monkeypatch.setattr(pipeline_module, "google_translate", lambda text, source, target: f"google({source}->{target}): {text}") + + captured = {} + + def fake_niutrans(text, source, target, api_key): + captured["niutrans"] = {"text": text, "source": source, "target": target, "api_key": api_key} + return f"niutrans({source}->{target}): {text}" + + def fake_libretranslate(text, source, target, base_url, api_key="", timeout=60): + captured["libretranslate"] = { + "text": text, + "source": source, + "target": target, + "base_url": base_url, + "api_key": api_key, + "timeout": timeout, + } + return f"libre({source}->{target}): {text}" + + monkeypatch.setattr(pipeline_module, "niutrans_translate", fake_niutrans) + monkeypatch.setattr(pipeline_module, "libretranslate_translate", fake_libretranslate) + + return captured + + +def test_step4_defaults_to_niutrans(base_config, stub_pipeline): + result = run_standard_pipeline("hello", base_config, target_lang="en") + + assert "niutrans" in stub_pipeline + assert stub_pipeline["niutrans"]["source"] == "fi" + assert stub_pipeline["niutrans"]["target"] == "en" + assert stub_pipeline["niutrans"]["api_key"] == "secret-niutrans" + + assert result["result"].startswith("niutrans(") + step4 = result["steps"][3] + assert step4["step"] == 4 + assert step4["engine"] == "Niutrans" + + +def test_step4_uses_libretranslate_when_configured(base_config, stub_pipeline): + base_config["pipeline"]["step4_provider"] = "libretranslate" + + result = run_standard_pipeline("hello", base_config, target_lang="en") + + assert "libretranslate" in stub_pipeline + assert stub_pipeline["libretranslate"]["source"] == "fi" + assert stub_pipeline["libretranslate"]["target"] == "en" + assert stub_pipeline["libretranslate"]["base_url"] == "http://libre.example.com:5000" + assert stub_pipeline["libretranslate"]["api_key"] == "secret-libre" + assert stub_pipeline["libretranslate"]["timeout"] == 30 + + assert result["result"].startswith("libre(") + step4 = result["steps"][3] + assert step4["step"] == 4 + assert step4["engine"] == "LibreTranslate" + + +def test_step4_preserves_response_shape(base_config, stub_pipeline): + base_config["pipeline"]["step4_provider"] = "libretranslate" + + result = run_standard_pipeline("hello", base_config, target_lang="en") + + assert "result" in result + assert "steps" in result + assert "processing_time_ms" in result + assert len(result["steps"]) == 4 + assert all("step" in s and "engine" in s and "output" in s for s in result["steps"])