customchat-app 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- customchat_app-0.1.0/LICENSE +21 -0
- customchat_app-0.1.0/PKG-INFO +100 -0
- customchat_app-0.1.0/README.md +84 -0
- customchat_app-0.1.0/customchat/__init__.py +2 -0
- customchat_app-0.1.0/customchat/__main__.py +2 -0
- customchat_app-0.1.0/customchat/accounts.py +95 -0
- customchat_app-0.1.0/customchat/actions.py +31 -0
- customchat_app-0.1.0/customchat/budget.py +74 -0
- customchat_app-0.1.0/customchat/cli.py +136 -0
- customchat_app-0.1.0/customchat/configchange.py +46 -0
- customchat_app-0.1.0/customchat/configdoctor.py +106 -0
- customchat_app-0.1.0/customchat/connectors/__init__.py +6 -0
- customchat_app-0.1.0/customchat/connectors/arxiv.py +28 -0
- customchat_app-0.1.0/customchat/connectors/base.py +46 -0
- customchat_app-0.1.0/customchat/connectors/http_json.py +46 -0
- customchat_app-0.1.0/customchat/connectors/local_files.py +188 -0
- customchat_app-0.1.0/customchat/connectors/open_sources.py +105 -0
- customchat_app-0.1.0/customchat/connectors/pubmed.py +60 -0
- customchat_app-0.1.0/customchat/connectors/web_search.py +42 -0
- customchat_app-0.1.0/customchat/exports.py +96 -0
- customchat_app-0.1.0/customchat/fetch.py +75 -0
- customchat_app-0.1.0/customchat/generators.py +334 -0
- customchat_app-0.1.0/customchat/hardware.py +217 -0
- customchat_app-0.1.0/customchat/launcher.py +43 -0
- customchat_app-0.1.0/customchat/mysqlstore.py +88 -0
- customchat_app-0.1.0/customchat/pdfread.py +97 -0
- customchat_app-0.1.0/customchat/permissions.py +21 -0
- customchat_app-0.1.0/customchat/pipeline.py +642 -0
- customchat_app-0.1.0/customchat/portable.py +128 -0
- customchat_app-0.1.0/customchat/prompts.py +137 -0
- customchat_app-0.1.0/customchat/providers.py +412 -0
- customchat_app-0.1.0/customchat/schema.py +200 -0
- customchat_app-0.1.0/customchat/secrets.py +64 -0
- customchat_app-0.1.0/customchat/server.py +603 -0
- customchat_app-0.1.0/customchat/store.py +332 -0
- customchat_app-0.1.0/customchat/suggestions.py +47 -0
- customchat_app-0.1.0/customchat/templates/minimal/app.yaml +16 -0
- customchat_app-0.1.0/customchat/templates/minimal/docs/about.md +7 -0
- customchat_app-0.1.0/customchat/terminal.py +15 -0
- customchat_app-0.1.0/customchat/theme.py +140 -0
- customchat_app-0.1.0/customchat/web/app.js +633 -0
- customchat_app-0.1.0/customchat/web/brand.js +37 -0
- customchat_app-0.1.0/customchat/web/codeeditor.LICENSE.txt +21 -0
- customchat_app-0.1.0/customchat/web/codeeditor.js +14 -0
- customchat_app-0.1.0/customchat/web/customselect.css +14 -0
- customchat_app-0.1.0/customchat/web/customselect.js +38 -0
- customchat_app-0.1.0/customchat/web/error.html +10 -0
- customchat_app-0.1.0/customchat/web/index.html +57 -0
- customchat_app-0.1.0/customchat/web/motion.css +240 -0
- customchat_app-0.1.0/customchat/web/motion.js +145 -0
- customchat_app-0.1.0/customchat/web/panels.js +142 -0
- customchat_app-0.1.0/customchat/web/pipeline.js +93 -0
- customchat_app-0.1.0/customchat/web/reading.js +38 -0
- customchat_app-0.1.0/customchat/web/settings.css +723 -0
- customchat_app-0.1.0/customchat/web/settings.html +88 -0
- customchat_app-0.1.0/customchat/web/settings.js +566 -0
- customchat_app-0.1.0/customchat/web/style.css +655 -0
- customchat_app-0.1.0/customchat/web/theme.js +116 -0
- customchat_app-0.1.0/customchat/websearch.py +113 -0
- customchat_app-0.1.0/customchat_app.egg-info/PKG-INFO +100 -0
- customchat_app-0.1.0/customchat_app.egg-info/SOURCES.txt +95 -0
- customchat_app-0.1.0/customchat_app.egg-info/dependency_links.txt +1 -0
- customchat_app-0.1.0/customchat_app.egg-info/entry_points.txt +3 -0
- customchat_app-0.1.0/customchat_app.egg-info/requires.txt +7 -0
- customchat_app-0.1.0/customchat_app.egg-info/top_level.txt +1 -0
- customchat_app-0.1.0/pyproject.toml +29 -0
- customchat_app-0.1.0/setup.cfg +4 -0
- customchat_app-0.1.0/tests/test_actions.py +27 -0
- customchat_app-0.1.0/tests/test_answer_export.py +30 -0
- customchat_app-0.1.0/tests/test_branch.py +30 -0
- customchat_app-0.1.0/tests/test_brand.py +8 -0
- customchat_app-0.1.0/tests/test_budget.py +71 -0
- customchat_app-0.1.0/tests/test_calm.py +7 -0
- customchat_app-0.1.0/tests/test_configchange.py +33 -0
- customchat_app-0.1.0/tests/test_configdoctor.py +28 -0
- customchat_app-0.1.0/tests/test_core.py +1030 -0
- customchat_app-0.1.0/tests/test_degrade.py +118 -0
- customchat_app-0.1.0/tests/test_fallbacks.py +29 -0
- customchat_app-0.1.0/tests/test_freshness.py +56 -0
- customchat_app-0.1.0/tests/test_host_flow.py +44 -0
- customchat_app-0.1.0/tests/test_launcher.py +27 -0
- customchat_app-0.1.0/tests/test_lock.py +23 -0
- customchat_app-0.1.0/tests/test_motion.py +97 -0
- customchat_app-0.1.0/tests/test_mysql.py +82 -0
- customchat_app-0.1.0/tests/test_permissions.py +60 -0
- customchat_app-0.1.0/tests/test_portable.py +95 -0
- customchat_app-0.1.0/tests/test_progress.py +20 -0
- customchat_app-0.1.0/tests/test_publish.py +31 -0
- customchat_app-0.1.0/tests/test_reading.py +10 -0
- customchat_app-0.1.0/tests/test_retrieval_plus.py +59 -0
- customchat_app-0.1.0/tests/test_review_refinements.py +20 -0
- customchat_app-0.1.0/tests/test_scope.py +39 -0
- customchat_app-0.1.0/tests/test_server_bind.py +14 -0
- customchat_app-0.1.0/tests/test_setup.py +37 -0
- customchat_app-0.1.0/tests/test_suggestions.py +48 -0
- customchat_app-0.1.0/tests/test_terminal.py +15 -0
- customchat_app-0.1.0/tests/test_ui_errors.py +23 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Harsh Kashyap
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: customchat-app
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Build a chat application over any evidence source from one YAML file.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/Harsh23Kashyap/customchat
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: PyYAML<7,>=6.0.3
|
|
11
|
+
Provides-Extra: documents
|
|
12
|
+
Requires-Dist: pypdf>=4; extra == "documents"
|
|
13
|
+
Provides-Extra: semantic
|
|
14
|
+
Requires-Dist: sentence-transformers>=3; extra == "semantic"
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# CustomChat
|
|
18
|
+
|
|
19
|
+
[](https://github.com/Harsh23Kashyap/customchat/actions/workflows/installer.yml)
|
|
20
|
+
|
|
21
|
+
A chat app over your documents, configured with one YAML file. Answers cite their sources. Includes offline Demo, saved chats, themes and a local configuration page.
|
|
22
|
+
|
|
23
|
+
## Start on a fresh computer
|
|
24
|
+
|
|
25
|
+
Open **Terminal** on macOS/Linux, or **PowerShell** on Windows. Copy the command for your computer. You do not need to install Python, Git, uv or uvx first.
|
|
26
|
+
|
|
27
|
+
**macOS / Linux** (needs curl, included on macOS and many Linux systems):
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
curl -fsSL https://raw.githubusercontent.com/Harsh23Kashyap/customchat/main/start.sh | sh
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Windows PowerShell**:
|
|
34
|
+
|
|
35
|
+
```powershell
|
|
36
|
+
& { $f = Join-Path ([IO.Path]::GetTempPath()) ([IO.Path]::GetRandomFileName() + '.ps1'); try { Invoke-WebRequest https://raw.githubusercontent.com/Harsh23Kashyap/customchat/main/start.ps1 -OutFile $f; & powershell -NoProfile -ExecutionPolicy Bypass -File $f } finally { Remove-Item $f -ErrorAction SilentlyContinue } }
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
These scripts detect uv or download it from its official installer, obtain Python 3.12 if needed, then run CustomChat with `uv tool run` (the same operation as `uvx`). No administrator access is needed. First setup needs internet and writable disk space. Review [start.sh](start.sh) or [start.ps1](start.ps1) before running downloaded software. Network restrictions or a locked-down work computer can still prevent setup; the terminal shows the error.
|
|
40
|
+
|
|
41
|
+
The app opens in your browser. If that does not happen, open the printed localhost URL. Starts with offline Demo: no API key, account, paid AI calls or cloud hosting. Press **Ctrl+C** to stop. Run the same command from the same folder to return to your chats. Your workspace is `customchat-app` in that folder, separate from the software environment. Existing app files are not overwritten.
|
|
42
|
+
|
|
43
|
+
### Already have uv?
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
uvx --python 3.12 --from https://github.com/Harsh23Kashyap/customchat/archive/refs/heads/main.zip customchat start
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
This archive route needs no Git. It installs from current `main`, which can change. PyPI publication of `customchat-app` is pending; do not use `uvx customchat` or `pip install customchat`, which target another project.
|
|
50
|
+
|
|
51
|
+
### Will it conflict with my other Python packages?
|
|
52
|
+
|
|
53
|
+
`uvx` runs CustomChat and its dependencies in a **separate tool environment**. It does not install packages into your system Python or another project's virtual environment. You do not need to create a venv for the commands above. The bootstrap also removes inherited `PYTHONPATH`/`PYTHONHOME` overrides for this run. Your documents and chats live outside uv's cache.
|
|
54
|
+
|
|
55
|
+
Isolation avoids shared dependency-version conflicts, not every possible computer problem. First install still needs internet; disk permissions, antivirus, proxies and platform support can affect setup. If the usual port is busy, CustomChat chooses another and prints its URL.
|
|
56
|
+
|
|
57
|
+
### Prefer pip? Use a separate venv
|
|
58
|
+
|
|
59
|
+
With **Python 3.10+** already installed, open a new empty folder and run:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
# macOS / Linux
|
|
63
|
+
python3 -m venv .venv
|
|
64
|
+
.venv/bin/python -m pip install https://github.com/Harsh23Kashyap/customchat/archive/refs/heads/main.zip
|
|
65
|
+
.venv/bin/python -m customchat start
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```powershell
|
|
69
|
+
# Windows
|
|
70
|
+
py -3 -m venv .venv
|
|
71
|
+
.\.venv\Scripts\python.exe -m pip install https://github.com/Harsh23Kashyap/customchat/archive/refs/heads/main.zip
|
|
72
|
+
.\.venv\Scripts\python.exe -m customchat start
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
These commands use the venv directly; no activation or PowerShell activation-policy change is needed. Avoid global `pip install` for this app. To check dependencies, run the venv's Python with `-m pip check`. Use a new folder or a new venv name rather than overwriting an environment you already use.
|
|
76
|
+
|
|
77
|
+
## Configure your own app
|
|
78
|
+
|
|
79
|
+
Choose an AI provider in Configuration or your app YAML. Keep keys in environment variables/private local storage, never YAML or Git.
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
uvx --python 3.12 --from https://github.com/Harsh23Kashyap/customchat/archive/refs/heads/main.zip customchat start --directory my-chat
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Edit `my-chat/app.yaml` and add documents in the app folder. [Configuration reference](docs/DESIGN.md).
|
|
86
|
+
|
|
87
|
+
For a chat-only interface, append `--lock-config` or set `CUSTOMCHAT_CONFIG=off`. Configuration pages and settings/key/model endpoints then return 404. Remove the lock on restart to configure again.
|
|
88
|
+
|
|
89
|
+
## More
|
|
90
|
+
|
|
91
|
+
- [Install choices, clone installers and validation](docs/INSTALL.md)
|
|
92
|
+
- [Hosting and deployment](docs/DEPLOY_OPTIONS.md) (no public deployment tested here)
|
|
93
|
+
- [Architecture](docs/DESIGN.md)
|
|
94
|
+
- Tests: `python -m unittest discover -s tests`
|
|
95
|
+
|
|
96
|
+
CustomChat is the conversation counterpart of [Custom-Nerd](https://github.com/Harsh23Kashyap/Custom-Nerd). DietChat and WirelessChat are included example apps.
|
|
97
|
+
|
|
98
|
+
## Authors and license
|
|
99
|
+
|
|
100
|
+
Harsh Kashyap, Shela Wu. Advisor: Dennis Shasha. MIT license. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# CustomChat
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Harsh23Kashyap/customchat/actions/workflows/installer.yml)
|
|
4
|
+
|
|
5
|
+
A chat app over your documents, configured with one YAML file. Answers cite their sources. Includes offline Demo, saved chats, themes and a local configuration page.
|
|
6
|
+
|
|
7
|
+
## Start on a fresh computer
|
|
8
|
+
|
|
9
|
+
Open **Terminal** on macOS/Linux, or **PowerShell** on Windows. Copy the command for your computer. You do not need to install Python, Git, uv or uvx first.
|
|
10
|
+
|
|
11
|
+
**macOS / Linux** (needs curl, included on macOS and many Linux systems):
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
curl -fsSL https://raw.githubusercontent.com/Harsh23Kashyap/customchat/main/start.sh | sh
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
**Windows PowerShell**:
|
|
18
|
+
|
|
19
|
+
```powershell
|
|
20
|
+
& { $f = Join-Path ([IO.Path]::GetTempPath()) ([IO.Path]::GetRandomFileName() + '.ps1'); try { Invoke-WebRequest https://raw.githubusercontent.com/Harsh23Kashyap/customchat/main/start.ps1 -OutFile $f; & powershell -NoProfile -ExecutionPolicy Bypass -File $f } finally { Remove-Item $f -ErrorAction SilentlyContinue } }
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
These scripts detect uv or download it from its official installer, obtain Python 3.12 if needed, then run CustomChat with `uv tool run` (the same operation as `uvx`). No administrator access is needed. First setup needs internet and writable disk space. Review [start.sh](start.sh) or [start.ps1](start.ps1) before running downloaded software. Network restrictions or a locked-down work computer can still prevent setup; the terminal shows the error.
|
|
24
|
+
|
|
25
|
+
The app opens in your browser. If that does not happen, open the printed localhost URL. Starts with offline Demo: no API key, account, paid AI calls or cloud hosting. Press **Ctrl+C** to stop. Run the same command from the same folder to return to your chats. Your workspace is `customchat-app` in that folder, separate from the software environment. Existing app files are not overwritten.
|
|
26
|
+
|
|
27
|
+
### Already have uv?
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
uvx --python 3.12 --from https://github.com/Harsh23Kashyap/customchat/archive/refs/heads/main.zip customchat start
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
This archive route needs no Git. It installs from current `main`, which can change. PyPI publication of `customchat-app` is pending; do not use `uvx customchat` or `pip install customchat`, which target another project.
|
|
34
|
+
|
|
35
|
+
### Will it conflict with my other Python packages?
|
|
36
|
+
|
|
37
|
+
`uvx` runs CustomChat and its dependencies in a **separate tool environment**. It does not install packages into your system Python or another project's virtual environment. You do not need to create a venv for the commands above. The bootstrap also removes inherited `PYTHONPATH`/`PYTHONHOME` overrides for this run. Your documents and chats live outside uv's cache.
|
|
38
|
+
|
|
39
|
+
Isolation avoids shared dependency-version conflicts, not every possible computer problem. First install still needs internet; disk permissions, antivirus, proxies and platform support can affect setup. If the usual port is busy, CustomChat chooses another and prints its URL.
|
|
40
|
+
|
|
41
|
+
### Prefer pip? Use a separate venv
|
|
42
|
+
|
|
43
|
+
With **Python 3.10+** already installed, open a new empty folder and run:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
# macOS / Linux
|
|
47
|
+
python3 -m venv .venv
|
|
48
|
+
.venv/bin/python -m pip install https://github.com/Harsh23Kashyap/customchat/archive/refs/heads/main.zip
|
|
49
|
+
.venv/bin/python -m customchat start
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```powershell
|
|
53
|
+
# Windows
|
|
54
|
+
py -3 -m venv .venv
|
|
55
|
+
.\.venv\Scripts\python.exe -m pip install https://github.com/Harsh23Kashyap/customchat/archive/refs/heads/main.zip
|
|
56
|
+
.\.venv\Scripts\python.exe -m customchat start
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
These commands use the venv directly; no activation or PowerShell activation-policy change is needed. Avoid global `pip install` for this app. To check dependencies, run the venv's Python with `-m pip check`. Use a new folder or a new venv name rather than overwriting an environment you already use.
|
|
60
|
+
|
|
61
|
+
## Configure your own app
|
|
62
|
+
|
|
63
|
+
Choose an AI provider in Configuration or your app YAML. Keep keys in environment variables/private local storage, never YAML or Git.
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
uvx --python 3.12 --from https://github.com/Harsh23Kashyap/customchat/archive/refs/heads/main.zip customchat start --directory my-chat
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Edit `my-chat/app.yaml` and add documents in the app folder. [Configuration reference](docs/DESIGN.md).
|
|
70
|
+
|
|
71
|
+
For a chat-only interface, append `--lock-config` or set `CUSTOMCHAT_CONFIG=off`. Configuration pages and settings/key/model endpoints then return 404. Remove the lock on restart to configure again.
|
|
72
|
+
|
|
73
|
+
## More
|
|
74
|
+
|
|
75
|
+
- [Install choices, clone installers and validation](docs/INSTALL.md)
|
|
76
|
+
- [Hosting and deployment](docs/DEPLOY_OPTIONS.md) (no public deployment tested here)
|
|
77
|
+
- [Architecture](docs/DESIGN.md)
|
|
78
|
+
- Tests: `python -m unittest discover -s tests`
|
|
79
|
+
|
|
80
|
+
CustomChat is the conversation counterpart of [Custom-Nerd](https://github.com/Harsh23Kashyap/Custom-Nerd). DietChat and WirelessChat are included example apps.
|
|
81
|
+
|
|
82
|
+
## Authors and license
|
|
83
|
+
|
|
84
|
+
Harsh Kashyap, Shela Wu. Advisor: Dennis Shasha. MIT license. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Accounts: sign up, sign in, sign out, change password. Standard library only.
|
|
2
|
+
Passwords are stored as salted PBKDF2 hashes. Sessions are random tokens kept only as hashes."""
|
|
3
|
+
import hashlib, hmac, os, re, secrets, time
|
|
4
|
+
|
|
5
|
+
ITER = 200_000
|
|
6
|
+
SESSION_DAYS = 30
|
|
7
|
+
EMAIL = re.compile(r"^[^@\s]{1,64}@[^@\s]{1,190}\.[^@\s]{2,}$")
|
|
8
|
+
|
|
9
|
+
TABLES = """
|
|
10
|
+
CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY, email TEXT UNIQUE, name TEXT, salt BLOB, hash BLOB, created REAL);
|
|
11
|
+
CREATE TABLE IF NOT EXISTS sessions(token_hash TEXT PRIMARY KEY, user TEXT, expires REAL);
|
|
12
|
+
CREATE TABLE IF NOT EXISTS attempts(key TEXT, ts REAL);
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _hash(pw, salt):
|
|
17
|
+
return hashlib.pbkdf2_hmac("sha256", pw.encode(), salt, ITER)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _th(token):
|
|
21
|
+
return hashlib.sha256(token.encode()).hexdigest()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Accounts:
|
|
25
|
+
def __init__(self, store, allow_signup=True):
|
|
26
|
+
self.s = store
|
|
27
|
+
self.allow_signup = allow_signup
|
|
28
|
+
with store.c() as c:
|
|
29
|
+
c.executescript(TABLES)
|
|
30
|
+
|
|
31
|
+
def _check_pw(self, pw):
|
|
32
|
+
if not isinstance(pw, str) or not 8 <= len(pw) <= 200:
|
|
33
|
+
raise ValueError("Password must be 8 to 200 characters")
|
|
34
|
+
|
|
35
|
+
def signup(self, email, name, pw):
|
|
36
|
+
if not self.allow_signup and self.s.q("SELECT 1 FROM users LIMIT 1"):
|
|
37
|
+
raise PermissionError("Sign-ups are closed")
|
|
38
|
+
email = str(email or "").strip().lower()
|
|
39
|
+
if not EMAIL.match(email):
|
|
40
|
+
raise ValueError("Enter a valid email address")
|
|
41
|
+
self._check_pw(pw)
|
|
42
|
+
if self.s.q("SELECT 1 FROM users WHERE email=?", (email,), one=True):
|
|
43
|
+
raise ValueError("An account with that email already exists")
|
|
44
|
+
uid, salt = "u_" + secrets.token_hex(8), os.urandom(16)
|
|
45
|
+
self.s.q("INSERT INTO users VALUES(?,?,?,?,?,?)", (uid, email, str(name or "").strip()[:60] or email.split("@")[0], salt, _hash(pw, salt), time.time()), write=True)
|
|
46
|
+
return uid
|
|
47
|
+
|
|
48
|
+
def _throttle(self, key):
|
|
49
|
+
now = time.time()
|
|
50
|
+
self.s.q("DELETE FROM attempts WHERE ts<?", (now - 600,), write=True)
|
|
51
|
+
if len(self.s.q("SELECT 1 FROM attempts WHERE key=?", (key,))) >= 8:
|
|
52
|
+
raise PermissionError("Too many attempts, try again in a few minutes")
|
|
53
|
+
|
|
54
|
+
def login(self, email, pw, ip=""):
|
|
55
|
+
email = str(email or "").strip().lower()
|
|
56
|
+
self._throttle(email)
|
|
57
|
+
u = self.s.q("SELECT * FROM users WHERE email=?", (email,), one=True)
|
|
58
|
+
ok = bool(u) and hmac.compare_digest(_hash(str(pw or ""), u["salt"]), u["hash"])
|
|
59
|
+
if not ok:
|
|
60
|
+
self.s.q("INSERT INTO attempts VALUES(?,?)", (email, time.time()), write=True)
|
|
61
|
+
raise PermissionError("Wrong email or password")
|
|
62
|
+
return u["id"], self._session(u["id"])
|
|
63
|
+
|
|
64
|
+
def _session(self, uid):
|
|
65
|
+
t = secrets.token_urlsafe(32)
|
|
66
|
+
self.s.q("INSERT INTO sessions VALUES(?,?,?)", (_th(t), uid, time.time() + SESSION_DAYS * 86400), write=True)
|
|
67
|
+
return t
|
|
68
|
+
|
|
69
|
+
def user_for(self, token):
|
|
70
|
+
if not token:
|
|
71
|
+
return None
|
|
72
|
+
r = self.s.q("SELECT u.id,u.email,u.name FROM sessions s JOIN users u ON u.id=s.user WHERE s.token_hash=? AND s.expires>?", (_th(token), time.time()), one=True)
|
|
73
|
+
return dict(r) if r else None
|
|
74
|
+
|
|
75
|
+
def logout(self, token):
|
|
76
|
+
if token:
|
|
77
|
+
self.s.q("DELETE FROM sessions WHERE token_hash=?", (_th(token),), write=True)
|
|
78
|
+
|
|
79
|
+
def change_password(self, uid, old, new, keep_token=None):
|
|
80
|
+
u = self.s.q("SELECT * FROM users WHERE id=?", (uid,), one=True)
|
|
81
|
+
if not u or not hmac.compare_digest(_hash(str(old or ""), u["salt"]), u["hash"]):
|
|
82
|
+
raise PermissionError("Current password is wrong")
|
|
83
|
+
self._check_pw(new)
|
|
84
|
+
salt = os.urandom(16)
|
|
85
|
+
self.s.q("UPDATE users SET salt=?,hash=? WHERE id=?", (salt, _hash(new, salt), uid), write=True)
|
|
86
|
+
# sign out every other device
|
|
87
|
+
self.s.q("DELETE FROM sessions WHERE user=? AND token_hash!=?", (uid, _th(keep_token or "")), write=True)
|
|
88
|
+
|
|
89
|
+
def delete_account(self, uid, pw):
|
|
90
|
+
u = self.s.q("SELECT * FROM users WHERE id=?", (uid,), one=True)
|
|
91
|
+
if not u or not hmac.compare_digest(_hash(str(pw or ""), u["salt"]), u["hash"]):
|
|
92
|
+
raise PermissionError("Password is wrong")
|
|
93
|
+
for t, col in (("sessions", "user"), ("chats", "owner"), ("topics", "owner"), ("feedback", "owner"), ("states", "owner"), ("profile", "owner"), ("uploads", "owner")):
|
|
94
|
+
self.s.q(f"DELETE FROM {t} WHERE {col}=?", (uid,), write=True)
|
|
95
|
+
self.s.q("DELETE FROM users WHERE id=?", (uid,), write=True)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Finite, registered actions with owner-bound, expiring, one-use review tickets."""
|
|
2
|
+
import copy,json,secrets,threading,time
|
|
3
|
+
REGISTRY={
|
|
4
|
+
'list_notes':{'label':'List my notes','mode':'read','fields':{}},
|
|
5
|
+
'save_note':{'label':'Save a private note','mode':'write','fields':{'title':{'type':'string','maxLength':120},'text':{'type':'string','maxLength':4000}}},
|
|
6
|
+
}
|
|
7
|
+
class Actions:
|
|
8
|
+
def __init__(self,cfg,store):self.cfg,self.store=cfg,store;self.lock=threading.RLock()
|
|
9
|
+
def catalog(self):
|
|
10
|
+
policy=self.cfg.get('actions',{});allow=policy.get('allow')
|
|
11
|
+
return [dict(id=k,**copy.deepcopy(v)) for k,v in REGISTRY.items() if (allow is None or k in allow) and (v['mode']!='write' or policy.get('writes',True))]
|
|
12
|
+
def prepare(self,owner,tool,args):
|
|
13
|
+
spec=next((x for x in self.catalog() if x['id']==tool),None)
|
|
14
|
+
if not spec:raise PermissionError('Action unavailable')
|
|
15
|
+
if not isinstance(args,dict) or set(args)!=set(spec['fields']):raise ValueError('Use exactly the declared action fields')
|
|
16
|
+
for k,f in spec['fields'].items():
|
|
17
|
+
if not isinstance(args[k],str) or not args[k].strip() or len(args[k])>f['maxLength']:raise ValueError(k+' needs nonempty text within '+str(f['maxLength'])+' characters')
|
|
18
|
+
ticket=secrets.token_urlsafe(24);payload={'tool':tool,'args':copy.deepcopy(args),'expires':time.time()+300,'used':False}
|
|
19
|
+
self.store.save_state(owner,'action-'+ticket,payload)
|
|
20
|
+
return dict(ticket=ticket,tool=tool,label=spec['label'],mode=spec['mode'],args=args,expires_in=300)
|
|
21
|
+
def execute(self,owner,ticket):
|
|
22
|
+
with self.lock:
|
|
23
|
+
p=self.store.get_state(owner,'action-'+str(ticket))
|
|
24
|
+
if p['used'] or time.time()>p['expires']:raise PermissionError('Review expired or already used')
|
|
25
|
+
if p['tool'] not in {x['id'] for x in self.catalog()}:raise PermissionError('Action no longer allowed')
|
|
26
|
+
p['used']=True;self.store.save_state(owner,'action-'+str(ticket),p)
|
|
27
|
+
if p['tool']=='save_note':
|
|
28
|
+
name='note-'+secrets.token_hex(8);self.store.save_state(owner,name,p['args']);return {'saved':True,'id':name}
|
|
29
|
+
if p['tool']=='list_notes':
|
|
30
|
+
return {'notes':[self.store.get_state(owner,n) for n in self.store.states(owner) if n.startswith('note-')][:100]}
|
|
31
|
+
raise PermissionError('Action unavailable')
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Durable, atomic request/call quotas. Dollar limits fail closed without a meter."""
|
|
2
|
+
import copy, datetime, hashlib, json, math, sqlite3, threading
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from .providers import ProviderError
|
|
5
|
+
|
|
6
|
+
DEFAULT = {'daily_questions': 0, 'user_daily_questions': 0, 'daily_model_calls': 0, 'spend_cap_usd': None}
|
|
7
|
+
|
|
8
|
+
class BudgetError(ProviderError):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
def clean(raw):
|
|
12
|
+
if not isinstance(raw, dict): raise ValueError('budget must be a mapping')
|
|
13
|
+
if set(raw) - set(DEFAULT): raise ValueError('Unknown budget key')
|
|
14
|
+
out = dict(DEFAULT, **raw)
|
|
15
|
+
for k in ('daily_questions','user_daily_questions','daily_model_calls'):
|
|
16
|
+
v=out[k]
|
|
17
|
+
if isinstance(v,bool) or not isinstance(v,int) or not math.isfinite(v) or not 0 <= v <= 10000000:
|
|
18
|
+
raise ValueError(k + ' must be a whole number from 0 to 10000000')
|
|
19
|
+
v=out['spend_cap_usd']
|
|
20
|
+
if v is not None and (isinstance(v,bool) or not isinstance(v,(int,float)) or not math.isfinite(v) or not 0 <= v <= 1000000):
|
|
21
|
+
raise ValueError('spend_cap_usd must be null or 0-1000000')
|
|
22
|
+
return out
|
|
23
|
+
|
|
24
|
+
class Budget:
|
|
25
|
+
def __init__(self, cfg, folder=None):
|
|
26
|
+
self.value=clean(cfg.get('budget',{}));self.folder=Path(folder) if folder else None
|
|
27
|
+
self.lock=threading.RLock()
|
|
28
|
+
if self.folder:
|
|
29
|
+
self.folder.mkdir(parents=True,exist_ok=True)
|
|
30
|
+
path=self.folder/'budget.json'
|
|
31
|
+
if path.exists():self.value=clean(json.loads(path.read_text()))
|
|
32
|
+
self.db=sqlite3.connect(str(self.folder/'budget.db') if self.folder else ':memory:',check_same_thread=False)
|
|
33
|
+
self.db.execute('CREATE TABLE IF NOT EXISTS usage(day TEXT, scope TEXT, kind TEXT, amount INTEGER, PRIMARY KEY(day,scope,kind))');self.db.commit()
|
|
34
|
+
def day(self):return datetime.datetime.now(datetime.timezone.utc).date().isoformat()
|
|
35
|
+
def take(self, kind, owner=''):
|
|
36
|
+
with self.lock:
|
|
37
|
+
limits=[('*', self.value['daily_questions' if kind=='question' else 'daily_model_calls'])]
|
|
38
|
+
if kind=='question':limits.append(('user:' + hashlib.sha256(str(owner).encode()).hexdigest(),self.value['user_daily_questions']))
|
|
39
|
+
day=self.day()
|
|
40
|
+
try:
|
|
41
|
+
self.db.execute('BEGIN IMMEDIATE')
|
|
42
|
+
for scope,limit in limits:
|
|
43
|
+
row=self.db.execute('SELECT amount FROM usage WHERE day=? AND scope=? AND kind=?',(day,scope,kind)).fetchone()
|
|
44
|
+
if limit and (row[0] if row else 0)>=limit:raise BudgetError('Daily '+kind+' limit reached. Resets at midnight UTC.',429)
|
|
45
|
+
for scope,_ in limits:
|
|
46
|
+
self.db.execute('INSERT INTO usage VALUES(?,?,?,1) ON CONFLICT(day,scope,kind) DO UPDATE SET amount=amount+1',(day,scope,kind))
|
|
47
|
+
self.db.commit()
|
|
48
|
+
except Exception:
|
|
49
|
+
self.db.rollback();raise
|
|
50
|
+
def save(self, raw):
|
|
51
|
+
value=clean(raw)
|
|
52
|
+
with self.lock:
|
|
53
|
+
if self.folder:
|
|
54
|
+
path=self.folder/'budget.json';tmp=self.folder/'budget.json.tmp'
|
|
55
|
+
tmp.write_text(json.dumps(value,indent=2));tmp.replace(path)
|
|
56
|
+
self.value=value
|
|
57
|
+
return self.view()
|
|
58
|
+
def view(self):
|
|
59
|
+
with self.lock:
|
|
60
|
+
rows=self.db.execute('SELECT kind,amount FROM usage WHERE day=? AND scope=?',(self.day(),'*')).fetchall()
|
|
61
|
+
return {'limits':copy.deepcopy(self.value),'used':dict(rows),'day':self.day(),'reset_timezone':'UTC',
|
|
62
|
+
'spend_meter':'unavailable','spend_note':'Dollar caps block all non-Demo model calls. No verified provider billing meter is installed.'}
|
|
63
|
+
def wrap(self, provider):return Guarded(provider,self)
|
|
64
|
+
|
|
65
|
+
class Guarded:
|
|
66
|
+
def __init__(self, provider, budget):self.provider,self.budget=provider,budget
|
|
67
|
+
def _check(self):
|
|
68
|
+
from .providers import Mock
|
|
69
|
+
if self.budget.value['spend_cap_usd'] is not None and not isinstance(self.provider,Mock):
|
|
70
|
+
raise BudgetError('Dollar cap is set, but verified billing is unavailable. Non-Demo model calls are blocked.',429)
|
|
71
|
+
self.budget.take('model')
|
|
72
|
+
def complete(self,messages):self._check();return self.provider.complete(messages)
|
|
73
|
+
def stream(self,messages):
|
|
74
|
+
self._check();yield from self.provider.stream(messages)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""customchat init | validate | run | doctor | ask"""
|
|
2
|
+
import argparse, json, os, shutil, sys, urllib.request
|
|
3
|
+
from . import schema, __version__
|
|
4
|
+
|
|
5
|
+
TEMPLATE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates", "minimal")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main(argv=None):
|
|
9
|
+
try:
|
|
10
|
+
_main(argv)
|
|
11
|
+
except schema.ConfigError as e:
|
|
12
|
+
sys.exit("There is a problem with the app file:\n %s" % e)
|
|
13
|
+
except KeyboardInterrupt:
|
|
14
|
+
sys.exit(0)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _main(argv=None):
|
|
18
|
+
ap = argparse.ArgumentParser(prog="customchat", description="Build a chat app over any evidence source.")
|
|
19
|
+
ap.add_argument("--version", action="version", version=__version__)
|
|
20
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
21
|
+
i = sub.add_parser("init", help="scaffold a new app folder"); i.add_argument("name")
|
|
22
|
+
st = sub.add_parser("start", help="create a local Demo workspace if absent, then run it")
|
|
23
|
+
st.add_argument("--directory", default="customchat-app", help="persistent workspace, never the tool cache")
|
|
24
|
+
st.add_argument("--port", type=int, default=8080)
|
|
25
|
+
st.add_argument("--no-browser", action="store_true")
|
|
26
|
+
st.add_argument("--lock-config", action="store_true")
|
|
27
|
+
v = sub.add_parser("validate", help="check an app file"); v.add_argument("app")
|
|
28
|
+
r = sub.add_parser("run", help="serve an app"); r.add_argument("app"); r.add_argument("--host"); r.add_argument("--port", type=int)
|
|
29
|
+
df = sub.add_parser("config-diff", help="preview changed keys and effects without values"); df.add_argument("app"); df.add_argument("candidate")
|
|
30
|
+
ca = sub.add_parser("config-apply", help="apply exact previewed config with private rollback backup")
|
|
31
|
+
ca.add_argument("app"); ca.add_argument("candidate"); ca.add_argument("--current-sha", required=True); ca.add_argument("--candidate-sha", required=True)
|
|
32
|
+
sub.add_parser("config-schema", help="print JSON Schema for editor autocomplete")
|
|
33
|
+
cd = sub.add_parser("config-doctor", help="offline line/key configuration checks"); cd.add_argument("app")
|
|
34
|
+
d = sub.add_parser("doctor", help="check provider and sources"); d.add_argument("app")
|
|
35
|
+
ev = sub.add_parser("eval", help="run questions from a file and report citation coverage"); ev.add_argument("app"); ev.add_argument("questions", help="text file, one question per line")
|
|
36
|
+
a = sub.add_parser("ask", help="ask one question from the terminal"); a.add_argument("app"); a.add_argument("question"); a.add_argument("--json", action="store_true")
|
|
37
|
+
ex = sub.add_parser("export", help="export app YAML, look, prompts and local documents, without private state")
|
|
38
|
+
ex.add_argument("app"); ex.add_argument("output", help="new ZIP file; never overwritten")
|
|
39
|
+
if argv is None: argv = sys.argv[1:]
|
|
40
|
+
if not argv: argv = ["start"]
|
|
41
|
+
args = ap.parse_args(argv)
|
|
42
|
+
if args.cmd == "config-diff":
|
|
43
|
+
from .configchange import diff
|
|
44
|
+
print(json.dumps(diff(args.app,args.candidate),indent=2)); return
|
|
45
|
+
if args.cmd == "config-apply":
|
|
46
|
+
from .configchange import apply
|
|
47
|
+
backup=apply(args.app,args.candidate,args.current_sha,args.candidate_sha)
|
|
48
|
+
print("Applied file. Rollback backup: " + backup)
|
|
49
|
+
print("Restart the app to load it. Review auth/source/network changes before launch."); return
|
|
50
|
+
if args.cmd == "config-schema":
|
|
51
|
+
from .configdoctor import editor_schema
|
|
52
|
+
print(json.dumps(editor_schema(), indent=2)); return
|
|
53
|
+
if args.cmd == "config-doctor":
|
|
54
|
+
from .configdoctor import report
|
|
55
|
+
if not report(args.app): raise SystemExit(1)
|
|
56
|
+
return
|
|
57
|
+
if args.cmd == "init":
|
|
58
|
+
if os.path.exists(args.name):
|
|
59
|
+
sys.exit("%s already exists" % args.name)
|
|
60
|
+
shutil.copytree(TEMPLATE, args.name, ignore=shutil.ignore_patterns("__pycache__", "*.db"))
|
|
61
|
+
print("Created %s/. Next: customchat run %s/app.yaml" % (args.name, args.name))
|
|
62
|
+
elif args.cmd == "export":
|
|
63
|
+
from .portable import export_app, ExportError
|
|
64
|
+
try:
|
|
65
|
+
m = export_app(args.app, args.output)
|
|
66
|
+
except (ExportError, OSError) as e:
|
|
67
|
+
sys.exit("Export stopped: %s" % e)
|
|
68
|
+
print("Exported %d app files to %s. No keys or private session state copied." % (len(m["files"]), args.output))
|
|
69
|
+
print("Review included local documents before sharing.")
|
|
70
|
+
elif args.cmd == "start":
|
|
71
|
+
from .launcher import start
|
|
72
|
+
start(args.directory, args.port, args.no_browser, args.lock_config)
|
|
73
|
+
elif args.cmd == "validate":
|
|
74
|
+
try:
|
|
75
|
+
c = schema.load(args.app)
|
|
76
|
+
except (schema.ConfigError, OSError) as e:
|
|
77
|
+
sys.exit("Invalid: %s" % e)
|
|
78
|
+
print("OK: %s, %d source(s), provider %s" % (c["app"]["title"], len(c["sources"]), c["provider"]["type"]))
|
|
79
|
+
elif args.cmd == "run":
|
|
80
|
+
from .server import serve
|
|
81
|
+
serve(args.app, args.host, args.port)
|
|
82
|
+
elif args.cmd == "doctor":
|
|
83
|
+
doctor(args.app)
|
|
84
|
+
elif args.cmd == "eval":
|
|
85
|
+
from .pipeline import Engine
|
|
86
|
+
from .store import Store
|
|
87
|
+
c = schema.load(args.app)
|
|
88
|
+
qs = [x.strip() for x in open(args.questions, encoding="utf-8") if x.strip() and not x.startswith("#")]
|
|
89
|
+
e = Engine(c, Store(":memory:"))
|
|
90
|
+
tot = cited = 0
|
|
91
|
+
for q in qs:
|
|
92
|
+
res = e.ask("eval", e.store.new_chat("eval"), q)
|
|
93
|
+
led = res["ledger"]; ok = sum(1 for l in led if l["supported"])
|
|
94
|
+
tot += len(led); cited += ok
|
|
95
|
+
print("%-60s evidence=%d claims=%d supported=%d" % (q[:60], len(res["evidence"]), len(led), ok))
|
|
96
|
+
print("coverage: %d/%d claims cited (%.0f%%)" % (cited, tot, 100.0 * cited / tot if tot else 0))
|
|
97
|
+
elif args.cmd == "ask":
|
|
98
|
+
from .pipeline import Engine
|
|
99
|
+
from .store import Store
|
|
100
|
+
c = schema.load(args.app)
|
|
101
|
+
e = Engine(c, Store(":memory:"))
|
|
102
|
+
res = e.ask("local", e.store.new_chat("local"), args.question)
|
|
103
|
+
if args.json:
|
|
104
|
+
print(json.dumps({k: res[k] for k in ("question", "standalone", "answer", "evidence", "ledger")}, indent=2)); return
|
|
105
|
+
print(res["answer"])
|
|
106
|
+
for ev in res["evidence"]:
|
|
107
|
+
print(" [%d] %s %s" % (ev["n"], ev["title"], ev["url"]))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def doctor(path):
|
|
111
|
+
c = schema.load(path)
|
|
112
|
+
p = c["provider"]
|
|
113
|
+
print("provider:", p["type"], p["model"])
|
|
114
|
+
if p["type"] == "ollama":
|
|
115
|
+
base = (p["base_url"] or os.environ.get("OLLAMA_BASE_URL") or "http://localhost:11434").rstrip("/")
|
|
116
|
+
try:
|
|
117
|
+
tags = json.loads(urllib.request.urlopen(base + "/api/tags", timeout=5).read())
|
|
118
|
+
names = [m["name"] for m in tags.get("models", [])]
|
|
119
|
+
ok = any(n == p["model"] or n.startswith(p["model"] + ":") for n in names)
|
|
120
|
+
print(" ollama reachable; model %s %s" % (p["model"], "installed" if ok else "NOT installed, run: ollama pull " + p["model"]))
|
|
121
|
+
except Exception as e:
|
|
122
|
+
print(" ollama NOT reachable at %s (%s). Install from https://ollama.com and run `ollama serve`." % (base, type(e).__name__))
|
|
123
|
+
elif p["api_key_env"]:
|
|
124
|
+
print(" key env %s: %s" % (p["api_key_env"], "set" if os.environ.get(p["api_key_env"]) else "NOT set"))
|
|
125
|
+
from .connectors import make_connector
|
|
126
|
+
for s in c["sources"]:
|
|
127
|
+
try:
|
|
128
|
+
conn = make_connector(s, c["_dir"])
|
|
129
|
+
n = len(getattr(conn, "docs", [])) if s["type"] == "local_files" else None
|
|
130
|
+
print(" source %s (%s): ready%s" % (s["id"], s["type"], "" if n is None else ", %d chunks" % n))
|
|
131
|
+
except Exception as e:
|
|
132
|
+
print(" source %s: FAILED %s" % (s["id"], e))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
main()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Offline config comparison and revision-checked replacement with private backup."""
|
|
2
|
+
import hashlib, os, tempfile, time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from .configdoctor import inspect
|
|
5
|
+
from .schema import ConfigError
|
|
6
|
+
|
|
7
|
+
def digest(path):return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
8
|
+
|
|
9
|
+
def checked(path):
|
|
10
|
+
issues,cfg=inspect(path)
|
|
11
|
+
if issues:raise ConfigError('Config has errors; run config-doctor first.')
|
|
12
|
+
return cfg
|
|
13
|
+
|
|
14
|
+
def diff(current,candidate):
|
|
15
|
+
a,b=checked(current),checked(candidate);changes=[]
|
|
16
|
+
labels={'provider':'model behavior or remote requests','sources':'retrieval corpus/access','auth':'visitor access','prompt':'answer behavior','budget':'request/model quotas','storage':'saved data location','server':'network exposure','memory':'conversation context','citations':'evidence display','retrieval':'ranking/cache behavior','app':'brand/UI','schema_version':'config compatibility'}
|
|
17
|
+
def walk(a,b,key=''):
|
|
18
|
+
if isinstance(a,dict) and isinstance(b,dict):
|
|
19
|
+
for k in sorted(set(a)|set(b)):
|
|
20
|
+
walk(a.get(k),b.get(k),(key+'.' if key else '')+str(k))
|
|
21
|
+
elif a!=b:
|
|
22
|
+
changes.append({'key':key,'effect':labels.get(key.split('.')[0],'configuration')})
|
|
23
|
+
walk(a,b)
|
|
24
|
+
return {'current_sha256':digest(current),'candidate_sha256':digest(candidate),'changes':changes}
|
|
25
|
+
|
|
26
|
+
def apply(current,candidate,current_sha,candidate_sha):
|
|
27
|
+
"""Never copies .env or state, never follows a target symlink, no hot reload."""
|
|
28
|
+
dst,src=Path(current),Path(candidate)
|
|
29
|
+
if dst.is_symlink() or src.is_symlink():raise ConfigError('Use regular config files, not symlinks.')
|
|
30
|
+
checked(dst);checked(src)
|
|
31
|
+
old,new=dst.read_bytes(),src.read_bytes()
|
|
32
|
+
if hashlib.sha256(old).hexdigest()!=current_sha or hashlib.sha256(new).hexdigest()!=candidate_sha:
|
|
33
|
+
raise ConfigError('Config changed since preview. Run config-diff again.')
|
|
34
|
+
if old==new:raise ConfigError('No file change to apply.')
|
|
35
|
+
name=dst.with_name(dst.name+'.backup-'+str(time.time_ns()))
|
|
36
|
+
fd=os.open(str(name),os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
|
37
|
+
with os.fdopen(fd,'wb') as f:f.write(old);f.flush();os.fsync(f.fileno())
|
|
38
|
+
fd,tmp=tempfile.mkstemp(prefix='.'+dst.name+'.',dir=str(dst.parent))
|
|
39
|
+
try:
|
|
40
|
+
with os.fdopen(fd,'wb') as f:f.write(new);f.flush();os.fsync(f.fileno())
|
|
41
|
+
# Catch edits during backup creation. This is not a multi-writer lock.
|
|
42
|
+
if dst.is_symlink() or digest(dst)!=current_sha:raise ConfigError('Current file changed; replacement stopped. Backup retained.')
|
|
43
|
+
os.replace(tmp,dst)
|
|
44
|
+
finally:
|
|
45
|
+
if os.path.exists(tmp):os.unlink(tmp)
|
|
46
|
+
return str(name)
|