chatify-app 0.2.0__py3-none-any.whl
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.
- chatify/__init__.py +2 -0
- chatify/__main__.py +2 -0
- chatify/accounts.py +95 -0
- chatify/actions.py +31 -0
- chatify/apipicks.py +45 -0
- chatify/appbuilder.py +260 -0
- chatify/budget.py +74 -0
- chatify/builddrafts.py +56 -0
- chatify/buildimages.py +34 -0
- chatify/buildjobs.py +263 -0
- chatify/bundle_review.py +39 -0
- chatify/cli.py +146 -0
- chatify/configchange.py +46 -0
- chatify/configdoctor.py +107 -0
- chatify/connectors/__init__.py +6 -0
- chatify/connectors/arxiv.py +37 -0
- chatify/connectors/base.py +45 -0
- chatify/connectors/bounded.py +44 -0
- chatify/connectors/http_json.py +66 -0
- chatify/connectors/local_files.py +198 -0
- chatify/connectors/open_sources.py +114 -0
- chatify/connectors/pubmed.py +60 -0
- chatify/connectors/recovery.py +34 -0
- chatify/connectors/web_search.py +43 -0
- chatify/draftaudit.py +20 -0
- chatify/editstate.py +64 -0
- chatify/exports.py +110 -0
- chatify/fetch.py +75 -0
- chatify/fileread.py +59 -0
- chatify/generator_design.py +94 -0
- chatify/generators.py +455 -0
- chatify/hardware.py +265 -0
- chatify/keybundle.py +45 -0
- chatify/launcher.py +46 -0
- chatify/management.py +161 -0
- chatify/modelcatalog.py +28 -0
- chatify/mysqlstore.py +88 -0
- chatify/nerdload.py +179 -0
- chatify/ollama_setup.py +138 -0
- chatify/pdfread.py +97 -0
- chatify/permissions.py +21 -0
- chatify/pipeline.py +863 -0
- chatify/portable.py +151 -0
- chatify/prompts.py +138 -0
- chatify/providers.py +482 -0
- chatify/readiness.py +33 -0
- chatify/schema.py +232 -0
- chatify/secrets.py +64 -0
- chatify/server.py +879 -0
- chatify/settingsguide.py +65 -0
- chatify/source_credentials.py +33 -0
- chatify/stage_contracts.py +24 -0
- chatify/store.py +412 -0
- chatify/suggestions.py +47 -0
- chatify/templates/minimal/app.yaml +16 -0
- chatify/templates/minimal/docs/about.md +7 -0
- chatify/terminal.py +15 -0
- chatify/theme.py +155 -0
- chatify/web/about.css +35 -0
- chatify/web/about.html +1 -0
- chatify/web/about.js +16 -0
- chatify/web/app.js +914 -0
- chatify/web/appbuilder.js +93 -0
- chatify/web/appearanceextras.js +13 -0
- chatify/web/brand.js +37 -0
- chatify/web/builderstudio.css +198 -0
- chatify/web/codeeditor.LICENSE.txt +21 -0
- chatify/web/codeeditor.js +14 -0
- chatify/web/components.css +151 -0
- chatify/web/configstate.js +30 -0
- chatify/web/customselect.css +26 -0
- chatify/web/customselect.js +12 -0
- chatify/web/emoji/1f389.png +0 -0
- chatify/web/emoji/1f389.webp +0 -0
- chatify/web/emoji/1f44b.png +0 -0
- chatify/web/emoji/1f44b.webp +0 -0
- chatify/web/emoji/1f44d.png +0 -0
- chatify/web/emoji/1f44d.webp +0 -0
- chatify/web/emoji/1f44f.png +0 -0
- chatify/web/emoji/1f44f.webp +0 -0
- chatify/web/emoji/1f525.png +0 -0
- chatify/web/emoji/1f525.webp +0 -0
- chatify/web/emoji/1f600.png +0 -0
- chatify/web/emoji/1f600.webp +0 -0
- chatify/web/emoji/1f602.png +0 -0
- chatify/web/emoji/1f602.webp +0 -0
- chatify/web/emoji/1f60a.png +0 -0
- chatify/web/emoji/1f60a.webp +0 -0
- chatify/web/emoji/1f60d.png +0 -0
- chatify/web/emoji/1f60d.webp +0 -0
- chatify/web/emoji/1f60e.png +0 -0
- chatify/web/emoji/1f60e.webp +0 -0
- chatify/web/emoji/1f62d.png +0 -0
- chatify/web/emoji/1f62d.webp +0 -0
- chatify/web/emoji/1f64c.png +0 -0
- chatify/web/emoji/1f64c.webp +0 -0
- chatify/web/emoji/1f680.png +0 -0
- chatify/web/emoji/1f680.webp +0 -0
- chatify/web/emoji/1f914.png +0 -0
- chatify/web/emoji/1f914.webp +0 -0
- chatify/web/emoji/1f916.png +0 -0
- chatify/web/emoji/1f916.webp +0 -0
- chatify/web/emoji/1f973.png +0 -0
- chatify/web/emoji/1f973.webp +0 -0
- chatify/web/emoji/2728.png +0 -0
- chatify/web/emoji/2728.webp +0 -0
- chatify/web/emoji/ATTRIBUTION.txt +88 -0
- chatify/web/emoji/STATIC-NOTO-LICENSE.txt +94 -0
- chatify/web/emoji/static-1f308.png +0 -0
- chatify/web/emoji/static-1f319.png +0 -0
- chatify/web/emoji/static-1f331.png +0 -0
- chatify/web/emoji/static-1f33b.png +0 -0
- chatify/web/emoji/static-1f34e.png +0 -0
- chatify/web/emoji/static-1f354.png +0 -0
- chatify/web/emoji/static-1f355.png +0 -0
- chatify/web/emoji/static-1f36a.png +0 -0
- chatify/web/emoji/static-1f375.png +0 -0
- chatify/web/emoji/static-1f382.png +0 -0
- chatify/web/emoji/static-1f389.png +0 -0
- chatify/web/emoji/static-1f38a.png +0 -0
- chatify/web/emoji/static-1f3af.png +0 -0
- chatify/web/emoji/static-1f3c6.png +0 -0
- chatify/web/emoji/static-1f431.png +0 -0
- chatify/web/emoji/static-1f436.png +0 -0
- chatify/web/emoji/static-1f438.png +0 -0
- chatify/web/emoji/static-1f43c.png +0 -0
- chatify/web/emoji/static-1f44b.png +0 -0
- chatify/web/emoji/static-1f44d.png +0 -0
- chatify/web/emoji/static-1f44e.png +0 -0
- chatify/web/emoji/static-1f44f.png +0 -0
- chatify/web/emoji/static-1f49a.png +0 -0
- chatify/web/emoji/static-1f49b.png +0 -0
- chatify/web/emoji/static-1f49c.png +0 -0
- chatify/web/emoji/static-1f4a1.png +0 -0
- chatify/web/emoji/static-1f4aa.png +0 -0
- chatify/web/emoji/static-1f4ac.png +0 -0
- chatify/web/emoji/static-1f4c4.png +0 -0
- chatify/web/emoji/static-1f4ce.png +0 -0
- chatify/web/emoji/static-1f4da.png +0 -0
- chatify/web/emoji/static-1f4f0.png +0 -0
- chatify/web/emoji/static-1f50d.png +0 -0
- chatify/web/emoji/static-1f50e.png +0 -0
- chatify/web/emoji/static-1f525.png +0 -0
- chatify/web/emoji/static-1f57a.png +0 -0
- chatify/web/emoji/static-1f600.png +0 -0
- chatify/web/emoji/static-1f601.png +0 -0
- chatify/web/emoji/static-1f602.png +0 -0
- chatify/web/emoji/static-1f603.png +0 -0
- chatify/web/emoji/static-1f604.png +0 -0
- chatify/web/emoji/static-1f60a.png +0 -0
- chatify/web/emoji/static-1f60c.png +0 -0
- chatify/web/emoji/static-1f60d.png +0 -0
- chatify/web/emoji/static-1f60e.png +0 -0
- chatify/web/emoji/static-1f62d.png +0 -0
- chatify/web/emoji/static-1f62e.png +0 -0
- chatify/web/emoji/static-1f634.png +0 -0
- chatify/web/emoji/static-1f642.png +0 -0
- chatify/web/emoji/static-1f64c.png +0 -0
- chatify/web/emoji/static-1f64f.png +0 -0
- chatify/web/emoji/static-1f680.png +0 -0
- chatify/web/emoji/static-1f913.png +0 -0
- chatify/web/emoji/static-1f914.png +0 -0
- chatify/web/emoji/static-1f916.png +0 -0
- chatify/web/emoji/static-1f917.png +0 -0
- chatify/web/emoji/static-1f91d.png +0 -0
- chatify/web/emoji/static-1f92f.png +0 -0
- chatify/web/emoji/static-1f951.png +0 -0
- chatify/web/emoji/static-1f970.png +0 -0
- chatify/web/emoji/static-1f973.png +0 -0
- chatify/web/emoji/static-1f979.png +0 -0
- chatify/web/emoji/static-1f98a.png +0 -0
- chatify/web/emoji/static-1f98b.png +0 -0
- chatify/web/emoji/static-1f9d1-200d-1f4bb.png +0 -0
- chatify/web/emoji/static-1f9d1-200d-1f680.png +0 -0
- chatify/web/emoji/static-1fae1.png +0 -0
- chatify/web/emoji/static-23f1-fe0f.png +0 -0
- chatify/web/emoji/static-2600-fe0f.png +0 -0
- chatify/web/emoji/static-2615.png +0 -0
- chatify/web/emoji/static-267e-fe0f.png +0 -0
- chatify/web/emoji/static-26a1.png +0 -0
- chatify/web/emoji/static-2705.png +0 -0
- chatify/web/emoji/static-2728.png +0 -0
- chatify/web/emoji/static-274c.png +0 -0
- chatify/web/emoji/static-2764-fe0f.png +0 -0
- chatify/web/emoji/static-2795.png +0 -0
- chatify/web/emoji/static-27a1-fe0f.png +0 -0
- chatify/web/emoji/static-2b06-fe0f.png +0 -0
- chatify/web/emoji/static-2b50.png +0 -0
- chatify/web/emoji.js +1 -0
- chatify/web/error.html +10 -0
- chatify/web/featurecatalog.js +25 -0
- chatify/web/feedback.css +25 -0
- chatify/web/feedback.js +12 -0
- chatify/web/icons/chatify.svg +1 -0
- chatify/web/index.html +60 -0
- chatify/web/loading.css +5 -0
- chatify/web/loading.js +12 -0
- chatify/web/logos/claude.svg +1 -0
- chatify/web/logos/deepseek.svg +1 -0
- chatify/web/logos/gemini.svg +1 -0
- chatify/web/logos/groq.svg +1 -0
- chatify/web/logos/mimo.svg +1 -0
- chatify/web/logos/minimax.svg +1 -0
- chatify/web/logos/mistral.svg +1 -0
- chatify/web/logos/ollama.svg +1 -0
- chatify/web/logos/openai.svg +1 -0
- chatify/web/logos/openrouter.svg +1 -0
- chatify/web/motion.css +472 -0
- chatify/web/motion.js +125 -0
- chatify/web/navigation.css +30 -0
- chatify/web/nerdload.js +25 -0
- chatify/web/panels.js +148 -0
- chatify/web/pipeline.js +164 -0
- chatify/web/reading.js +39 -0
- chatify/web/refinements.css +115 -0
- chatify/web/settings.css +1336 -0
- chatify/web/settings.html +94 -0
- chatify/web/settings.js +968 -0
- chatify/web/settingsfind.js +23 -0
- chatify/web/settingsguide.js +22 -0
- chatify/web/settingssearch.js +12 -0
- chatify/web/settingssimple.js +33 -0
- chatify/web/settingstabs.css +338 -0
- chatify/web/sourcekeys.js +16 -0
- chatify/web/splitters.js +139 -0
- chatify/web/style.css +935 -0
- chatify/web/team/dennis.jpg +0 -0
- chatify/web/team/harsh.jpg +0 -0
- chatify/web/theme.js +168 -0
- chatify/web/tour.css +29 -0
- chatify/web/tour.js +68 -0
- chatify/web/workspace.js +25 -0
- chatify/websearch.py +113 -0
- chatify/workspace.py +50 -0
- chatify_app-0.2.0.dist-info/METADATA +61 -0
- chatify_app-0.2.0.dist-info/RECORD +240 -0
- chatify_app-0.2.0.dist-info/WHEEL +5 -0
- chatify_app-0.2.0.dist-info/entry_points.txt +5 -0
- chatify_app-0.2.0.dist-info/licenses/LICENSE +21 -0
- chatify_app-0.2.0.dist-info/top_level.txt +1 -0
chatify/mysqlstore.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Optional MySQL/MariaDB backend for Store. SQLite stays the default and the fallback.
|
|
2
|
+
Turned on by the CHATIFY_DB_URL environment variable, for example mysql://user:pass@host:3306/dbname.
|
|
3
|
+
The URL is read from the environment only, so a password never sits in a file in the repo.
|
|
4
|
+
Needs the PyMySQL package (pip install -r requirements-mysql.txt). If anything is missing or
|
|
5
|
+
unreachable, Store falls back to SQLite and says so."""
|
|
6
|
+
import os, re
|
|
7
|
+
from urllib.parse import urlparse, unquote
|
|
8
|
+
|
|
9
|
+
LONG = {"answer", "evidence", "ledger", "text", "payload", "summary", "standalone", "question"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse(url):
|
|
13
|
+
u = urlparse(url)
|
|
14
|
+
if u.scheme not in ("mysql", "mariadb") or not u.hostname:
|
|
15
|
+
raise ValueError("CHATIFY_DB_URL must look like mysql://user:password@host:3306/dbname")
|
|
16
|
+
return dict(host=u.hostname, port=u.port or 3306, user=unquote(u.username or ""), password=unquote(u.password or ""),
|
|
17
|
+
database=(u.path or "/").lstrip("/") or "chatify")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sql(s):
|
|
21
|
+
"""Translate the SQLite statements the app uses into MySQL."""
|
|
22
|
+
s = s.replace("%", "%%").replace("?", "%s")
|
|
23
|
+
s = re.sub(r"(?i)^\s*INSERT OR REPLACE", "REPLACE", s)
|
|
24
|
+
s = re.sub(r"(?<![`\w])key(?![`\w])", "`key`", s)
|
|
25
|
+
return s
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ddl(stmt):
|
|
29
|
+
stmt = stmt.strip()
|
|
30
|
+
if not stmt:
|
|
31
|
+
return None
|
|
32
|
+
stmt = re.sub(r"(?i)^CREATE INDEX IF NOT EXISTS", "CREATE INDEX", stmt)
|
|
33
|
+
stmt = re.sub(r"(?i)\bREAL\b", "DOUBLE", stmt)
|
|
34
|
+
stmt = re.sub(r"(?i)\bBLOB\b", "LONGBLOB", stmt)
|
|
35
|
+
def col(m):
|
|
36
|
+
n = m.group(1)
|
|
37
|
+
return n + (" LONGTEXT" if n.lower() in LONG else " VARCHAR(190)")
|
|
38
|
+
stmt = re.sub(r"(?i)\b(\w+) TEXT\b", col, stmt)
|
|
39
|
+
stmt = re.sub(r"(?i)(LONGTEXT) DEFAULT ''", r"\1", stmt)
|
|
40
|
+
stmt = re.sub(r"(?<![`\w])key(?![`\w])", "`key`", stmt)
|
|
41
|
+
return stmt
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Conn:
|
|
45
|
+
"""Looks enough like a sqlite3 connection for Store and Accounts."""
|
|
46
|
+
def __init__(self, cfg, driver=None):
|
|
47
|
+
if driver is None:
|
|
48
|
+
import pymysql
|
|
49
|
+
driver = pymysql
|
|
50
|
+
self.db = driver.connect(charset="utf8mb4", autocommit=False, connect_timeout=8,
|
|
51
|
+
cursorclass=driver.cursors.DictCursor, **cfg)
|
|
52
|
+
|
|
53
|
+
def execute(self, q, args=()):
|
|
54
|
+
cur = self.db.cursor()
|
|
55
|
+
cur.execute(sql(q), tuple(args))
|
|
56
|
+
return cur
|
|
57
|
+
|
|
58
|
+
def executescript(self, script):
|
|
59
|
+
for part in script.split(";"):
|
|
60
|
+
d = ddl(part)
|
|
61
|
+
if not d:
|
|
62
|
+
continue
|
|
63
|
+
try:
|
|
64
|
+
self.execute(d)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
if "1061" in str(e) or "Duplicate key name" in str(e):
|
|
67
|
+
continue
|
|
68
|
+
raise
|
|
69
|
+
self.db.commit()
|
|
70
|
+
|
|
71
|
+
def commit(self):
|
|
72
|
+
self.db.commit()
|
|
73
|
+
|
|
74
|
+
def close(self):
|
|
75
|
+
try:
|
|
76
|
+
self.db.close()
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
def __enter__(self):
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
def __exit__(self, *a):
|
|
84
|
+
if a[0] is None:
|
|
85
|
+
self.db.commit()
|
|
86
|
+
else:
|
|
87
|
+
self.db.rollback()
|
|
88
|
+
return False
|
chatify/nerdload.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Review portable Nerds without importing code, then launch in a separate local workspace."""
|
|
2
|
+
import base64,hashlib,io,json,os,re,shutil,subprocess,sys,threading,zipfile
|
|
3
|
+
from pathlib import Path,PurePosixPath
|
|
4
|
+
import yaml
|
|
5
|
+
from . import schema
|
|
6
|
+
from .portable import _check_values,ExportError
|
|
7
|
+
MAX_UPLOAD=8*1024*1024
|
|
8
|
+
MAX_EXPANDED=32*1024*1024
|
|
9
|
+
_ALLOWED={'.yaml','.yml','.json','.md','.txt','.csv','.pdf','.py'}
|
|
10
|
+
class NerdLoader:
|
|
11
|
+
def __init__(self,folder):
|
|
12
|
+
self.root=Path(folder)/'nerds';self.pending={};self.children=[];self.workspaces={};self.lock=threading.Lock()
|
|
13
|
+
if self.root.is_dir():
|
|
14
|
+
for folder in self.root.glob('nerd-*'):
|
|
15
|
+
if folder.is_symlink() or not folder.is_dir():continue
|
|
16
|
+
try:
|
|
17
|
+
config=yaml.safe_load((folder/'app.yaml').read_text());title=str(config['app']['title'])[:120]
|
|
18
|
+
self.workspaces[folder.name]={'folder':folder,'process':None,'title':title,'url':''}
|
|
19
|
+
except (OSError,ValueError,TypeError,KeyError):pass
|
|
20
|
+
def review(self,name,encoded):
|
|
21
|
+
try:data=base64.b64decode(encoded,validate=True)
|
|
22
|
+
except Exception:raise ValueError('Invalid upload') from None
|
|
23
|
+
if sum(p.poll() is None for p in self.children)>=4:raise ValueError('Up to four Nerds can run here. Stop a running workspace before loading more.')
|
|
24
|
+
if len(data)>MAX_UPLOAD:raise ValueError('Bundle limit is 8 MB')
|
|
25
|
+
files={}
|
|
26
|
+
if name.lower().endswith('.zip'):
|
|
27
|
+
try:
|
|
28
|
+
with zipfile.ZipFile(io.BytesIO(data)) as z:
|
|
29
|
+
if len(z.infolist())>500:raise ValueError('Too many files')
|
|
30
|
+
# Validate paths and private names before ignoring benign metadata.
|
|
31
|
+
for info in z.infolist():
|
|
32
|
+
path=PurePosixPath(info.filename)
|
|
33
|
+
if path.is_absolute() or '..' in path.parts or '\\' in info.filename or re.match(r'^[A-Za-z]:',info.filename):raise ValueError('Unsafe path in bundle: '+info.filename)
|
|
34
|
+
if (info.external_attr>>16)&0o170000==0o120000:raise ValueError('Symlinks are not allowed: '+info.filename)
|
|
35
|
+
if any(re.match(r'^\.env(?:$|[._-])',part,re.I) or part.lower() in ('.ssh','.aws','.azure','.gnupg','.npmrc','.pypirc','.netrc') for part in path.parts):raise ValueError('Private file is not allowed: '+info.filename)
|
|
36
|
+
names={str(PurePosixPath(i.filename).name) for i in z.infolist() if not i.is_dir()}
|
|
37
|
+
if 'pyproject.toml' in names and any('/chatify/__init__.py' in '/'+i.filename for i in z.infolist()):raise ValueError('This is the Chatify source ZIP, not a Nerd bundle. Install it to run Chatify. To share a Nerd, use Download app ZIP in Configuration.')
|
|
38
|
+
total=0
|
|
39
|
+
for info in z.infolist():
|
|
40
|
+
path=PurePosixPath(info.filename)
|
|
41
|
+
if path.is_absolute() or '..' in path.parts or '\\' in info.filename or re.match(r'^[A-Za-z]:',info.filename):raise ValueError('Unsafe path in bundle')
|
|
42
|
+
if (info.external_attr>>16)&0o170000==0o120000:raise ValueError('Symlinks are not allowed')
|
|
43
|
+
if info.is_dir():continue
|
|
44
|
+
benign={'.gitignore','.gitattributes','.editorconfig','.DS_Store','.github','.git','__MACOSX'}
|
|
45
|
+
hidden=[x for x in path.parts if x.startswith('.') or x=='__MACOSX']
|
|
46
|
+
if hidden:
|
|
47
|
+
if all(x in benign or x.startswith('._') for x in hidden):continue
|
|
48
|
+
raise ValueError('Unsupported hidden file: '+info.filename)
|
|
49
|
+
if re.search(r'(secret|credential|password|token|chatify\.db|accounts|sessions)',info.filename,re.I):raise ValueError('Private file is not allowed: '+info.filename)
|
|
50
|
+
if len(info.filename)>240:raise ValueError('Path too long')
|
|
51
|
+
if path.suffix.lower() not in _ALLOWED:raise ValueError('Unsupported file: '+info.filename)
|
|
52
|
+
total+=info.file_size
|
|
53
|
+
if total>MAX_EXPANDED:raise ValueError('Expanded bundle is too large')
|
|
54
|
+
if info.filename in files:raise ValueError('Duplicate path')
|
|
55
|
+
files[info.filename]=z.read(info)
|
|
56
|
+
except zipfile.BadZipFile:raise ValueError('Invalid ZIP') from None
|
|
57
|
+
elif name.lower().endswith(('.yaml','.yml','.json')):files['app.yaml']=data
|
|
58
|
+
else:raise ValueError('Choose a Nerd ZIP or YAML/JSON app file')
|
|
59
|
+
apps=[n for n in files if PurePosixPath(n).name in ('app.yaml','app.yml','app.json')]
|
|
60
|
+
if len(apps)!=1:raise ValueError('Bundle must contain exactly one app.yaml (or app.json)')
|
|
61
|
+
prefix=str(PurePosixPath(apps[0]).parent)
|
|
62
|
+
if prefix!='.':
|
|
63
|
+
if any(not n.startswith(prefix+'/') for n in files):raise ValueError('All files must be inside the app folder')
|
|
64
|
+
files={n[len(prefix)+1:]:v for n,v in files.items()}
|
|
65
|
+
appname=next(n for n in files if PurePosixPath(n).name in ('app.yaml','app.yml','app.json'))
|
|
66
|
+
try:raw=yaml.safe_load(files[appname].decode('utf-8'))
|
|
67
|
+
except Exception:raise ValueError('Invalid app YAML/JSON') from None
|
|
68
|
+
if isinstance(raw,dict) and 'articles' in raw and 'app' not in raw and 'provider' not in raw and 'sources' not in raw:
|
|
69
|
+
raise ValueError('This looks like an API response, not a Nerd app. In Code helpers, open Search connector, then Match a real response and paste this JSON there. It will not be imported or run.')
|
|
70
|
+
from .keybundle import extract
|
|
71
|
+
raw,imported_keys=extract(raw)
|
|
72
|
+
try:cfg=schema.validate(raw);_check_values(cfg)
|
|
73
|
+
except (schema.ConfigError,ExportError) as e:raise ValueError(str(e)) from None
|
|
74
|
+
files[appname]=yaml.safe_dump(cfg,sort_keys=False).encode()
|
|
75
|
+
for name,content in files.items():
|
|
76
|
+
if name.endswith('.json') and name!=appname:
|
|
77
|
+
try:_check_values(json.loads(content))
|
|
78
|
+
except (ValueError,ExportError):raise ValueError('Invalid or private JSON file: '+name) from None
|
|
79
|
+
if cfg['auth']['mode']!='none':raise ValueError('Local imported Nerds must use auth.mode: none. Set up hosted authentication separately.')
|
|
80
|
+
for name in ('data/theme.json','data/prompts.json'):
|
|
81
|
+
if name in files:
|
|
82
|
+
from . import theme,prompts
|
|
83
|
+
d=json.loads(files[name])
|
|
84
|
+
if name.endswith('theme.json'):files[name]=json.dumps(theme.clean(d)).encode()
|
|
85
|
+
elif not isinstance(d,dict) or set(d)-{'text','on'} or not isinstance(d.get('text',{}),dict) or not isinstance(d.get('on',{}),dict) or any(k not in prompts.STAGES or not isinstance(v,str) or len(v)>prompts.MAX_LEN for k,v in d.get('text',{}).items()) or any(k not in prompts.STAGES or type(v)!=bool for k,v in d.get('on',{}).items()):raise ValueError('Invalid prompt settings')
|
|
86
|
+
empty_sources=[]
|
|
87
|
+
for src in cfg['sources']:
|
|
88
|
+
for field in ('path','module'):
|
|
89
|
+
v=src.get(field)
|
|
90
|
+
if not v:continue
|
|
91
|
+
p=PurePosixPath(v)
|
|
92
|
+
if p.is_absolute() or '..' in p.parts or '\\' in v:raise ValueError('Sources must stay inside the bundle')
|
|
93
|
+
if src['type']=='local_files':
|
|
94
|
+
path=src.get('path','docs').rstrip('/')
|
|
95
|
+
if not any(n.startswith(path+'/') for n in files):empty_sources.append((src['label'],path))
|
|
96
|
+
if src['type']=='python':
|
|
97
|
+
mod=src.get('entry','').partition(':')[0]
|
|
98
|
+
if not re.fullmatch(r'[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*',mod):raise ValueError('Invalid Python module entry')
|
|
99
|
+
if mod.replace('.','/')+'.py' not in files:raise ValueError('Python connector module is missing from bundle')
|
|
100
|
+
for src in cfg['sources']:
|
|
101
|
+
if src['type']=='python':src['execution']='bounded'
|
|
102
|
+
cfg['storage']={'path':'data/chatify.db'};cfg['server']['host']='127.0.0.1';cfg['actions']={'allow':[],'writes':False}
|
|
103
|
+
files.pop(appname);files['app.yaml']=yaml.safe_dump(cfg,sort_keys=False).encode()
|
|
104
|
+
code=[n for n in files if n.endswith('.py')]
|
|
105
|
+
if sum(len(files[n]) for n in code)>200000:raise ValueError('Connector code is too large to review here')
|
|
106
|
+
envs=sorted({src.get('api_key_env') for src in cfg['sources'] if src.get('api_key_env')}|({cfg['provider']['api_key_env']} if cfg['provider'].get('api_key_env') else set()))
|
|
107
|
+
from .readiness import check
|
|
108
|
+
readiness=check(cfg,files,raw)
|
|
109
|
+
for label,path in empty_sources:readiness['rows'].append({'kind':'setup','item':label,'message':'No documents included for '+path+'. An empty source folder will be created. Add documents in the new workspace before asking source-backed questions.'})
|
|
110
|
+
if not readiness['can_load']:raise ValueError('Python connector does not compile. Fix it before loading.')
|
|
111
|
+
envs=readiness['required_env']
|
|
112
|
+
token=hashlib.sha256(data).hexdigest()
|
|
113
|
+
with self.lock:
|
|
114
|
+
if len(self.pending)>=4:self.pending.pop(next(iter(self.pending)))
|
|
115
|
+
self.pending[token]=(files,cfg,code,imported_keys)
|
|
116
|
+
from . import theme
|
|
117
|
+
look=theme.clean(json.loads(files['data/theme.json'])) if 'data/theme.json' in files else theme.clean(cfg.get('appearance',{}))
|
|
118
|
+
preview={'title':look.get('txt_title') or cfg['app']['title'],'tagline':look.get('txt_tagline') or cfg['app']['tagline'],'mode':look.get('mode','light'),'colors':look.get('dark' if look.get('mode')=='dark' else 'light',{}),'examples':cfg['app'].get('examples',[])[:3],'files':sorted(files),'static':True}
|
|
119
|
+
from . import bundle_review
|
|
120
|
+
return {'auto_review':bundle_review.review(cfg,files),'preview':preview,'readiness':readiness,'token':token,'title':cfg['app']['title'],'tagline':cfg['app']['tagline'],'provider':cfg['provider']['type'],'model':cfg['provider']['model'],'sources':[{'id':s['id'],'label':s.get('label',s['id']),'type':s['type']} for s in cfg['sources']],'required_env':envs,'imported_key_count':len(imported_keys),'code':[{'path':n,'text':files[n].decode('utf-8')} for n in code],'warnings':['Python uses a bounded process, NOT a sandbox. Host files/network remain accessible; code review required.','Separate local workspace. Current app is not overwritten.',str(len(imported_keys))+' API key entries will be placed in this workspace private key storage. Values are not shown. Accounts and chat history are not imported.','Imported local apps disable write actions. Review remote sources before asking a question.']}
|
|
121
|
+
def load(self,token,allow_code=False):
|
|
122
|
+
with self.lock:item=self.pending.get(token)
|
|
123
|
+
if not item:raise ValueError('Review the bundle first')
|
|
124
|
+
files,cfg,code,imported_keys=item
|
|
125
|
+
if sum(p.poll() is None for p in self.children)>=4:raise ValueError('Maximum four running Nerds. Stop a running workspace before loading more.')
|
|
126
|
+
if code and not allow_code:raise ValueError('Review Python code and explicitly allow it before loading')
|
|
127
|
+
from .launcher import pick_port
|
|
128
|
+
import tempfile
|
|
129
|
+
self.root.mkdir(parents=True,exist_ok=True)
|
|
130
|
+
folder=Path(tempfile.mkdtemp(prefix='nerd-',dir=str(self.root)))
|
|
131
|
+
for name,data in files.items():
|
|
132
|
+
p=folder/name;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(data)
|
|
133
|
+
for src in cfg['sources']:
|
|
134
|
+
if src['type']=='local_files':(folder/src.get('path','docs')).mkdir(parents=True,exist_ok=True)
|
|
135
|
+
port=pick_port(8090)
|
|
136
|
+
cfg['server']['port']=port;(folder/'app.yaml').write_text(yaml.safe_dump(cfg,sort_keys=False))
|
|
137
|
+
if imported_keys:
|
|
138
|
+
from .secrets import SecretStore
|
|
139
|
+
key_store=SecretStore(str(folder/'data'))
|
|
140
|
+
for name,value in imported_keys.items():key_store.set(name,value)
|
|
141
|
+
log=open(folder/'run.log','wb')
|
|
142
|
+
declared_env={cfg['provider'].get('api_key_env','')}|{src.get('api_key_env','') for src in cfg['sources']}
|
|
143
|
+
child=subprocess.Popen([sys.executable,'-m','chatify','run',str(folder/'app.yaml'),'--no-browser'],stdout=log,stderr=subprocess.STDOUT,cwd=str(Path(__file__).resolve().parents[1]),env={k:v for k,v in os.environ.items() if not re.search(r'(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)',k,re.I) or k in declared_env})
|
|
144
|
+
log.close();self.children.append(child);self.workspaces[folder.name]={'folder':folder,'process':child,'title':cfg['app']['title'],'url':'http://127.0.0.1:'+str(port)}
|
|
145
|
+
import time,urllib.request
|
|
146
|
+
url='http://127.0.0.1:'+str(port)
|
|
147
|
+
for _ in range(40):
|
|
148
|
+
if child.poll() is not None:raise ValueError('Nerd could not start. Check dependencies and connector setup in '+str(folder/'run.log'))
|
|
149
|
+
try:
|
|
150
|
+
with urllib.request.urlopen(url+'/api/health',timeout=.5):return {'url':url,'title':cfg['app']['title'],'workspace':str(folder)}
|
|
151
|
+
except OSError:time.sleep(.1)
|
|
152
|
+
child.terminate();raise ValueError('Nerd did not become ready; current app is unchanged')
|
|
153
|
+
def list(self):
|
|
154
|
+
with self.lock:
|
|
155
|
+
return [{'id':key,'title':v['title'],'url':v['url'],'running':v['process'] is not None and v['process'].poll() is None} for key,v in self.workspaces.items()]
|
|
156
|
+
def stop(self,key):
|
|
157
|
+
with self.lock:item=self.workspaces.get(key)
|
|
158
|
+
if not item:raise ValueError('Unknown imported workspace')
|
|
159
|
+
proc=item['process']
|
|
160
|
+
if proc and proc.poll() is None:
|
|
161
|
+
proc.terminate()
|
|
162
|
+
try:proc.wait(timeout=3)
|
|
163
|
+
except subprocess.TimeoutExpired:proc.kill();proc.wait()
|
|
164
|
+
return {'stopped':True}
|
|
165
|
+
def remove(self,key,confirmed):
|
|
166
|
+
if confirmed is not True:raise ValueError('Confirm removal of this imported workspace')
|
|
167
|
+
with self.lock:item=self.workspaces.get(key)
|
|
168
|
+
if not item:raise ValueError('Unknown imported workspace')
|
|
169
|
+
self.stop(key);folder=item['folder']
|
|
170
|
+
if folder.is_symlink() or folder.parent.resolve()!=self.root.resolve():raise ValueError('Workspace path changed; removal stopped')
|
|
171
|
+
shutil.rmtree(folder)
|
|
172
|
+
with self.lock:self.workspaces.pop(key,None)
|
|
173
|
+
return {'removed':True}
|
|
174
|
+
def close(self):
|
|
175
|
+
for child in self.children:
|
|
176
|
+
if child.poll() is None:
|
|
177
|
+
child.terminate()
|
|
178
|
+
try:child.wait(timeout=3)
|
|
179
|
+
except subprocess.TimeoutExpired:child.kill();child.wait()
|
chatify/ollama_setup.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Opt-in local Ollama installer. Fixed official sources, no user shell commands."""
|
|
2
|
+
import hashlib, os, platform, secrets, shutil, subprocess, tempfile, threading, time, urllib.request
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
from . import hardware
|
|
6
|
+
SOURCES = {'Windows': 'https://ollama.com/download/OllamaSetup.exe', 'Darwin': 'https://ollama.com/download/Ollama.dmg', 'Linux': 'https://ollama.com/install.sh'}
|
|
7
|
+
PAGES = {'Windows': 'https://ollama.com/download/windows', 'Darwin': 'https://ollama.com/download/mac', 'Linux': 'https://ollama.com/download/linux'}
|
|
8
|
+
BASE = 'http://127.0.0.1:11434'
|
|
9
|
+
LIMIT = 2 * 1024 ** 3
|
|
10
|
+
|
|
11
|
+
def detect():
|
|
12
|
+
system = platform.system()
|
|
13
|
+
binary = shutil.which('ollama')
|
|
14
|
+
if not binary and system == 'Darwin':
|
|
15
|
+
p = Path('/Applications/Ollama.app/Contents/Resources/ollama')
|
|
16
|
+
if p.is_file(): binary = str(p)
|
|
17
|
+
if not binary and system == 'Windows':
|
|
18
|
+
p = Path(os.environ.get('LOCALAPPDATA', '')) / 'Programs/Ollama/ollama.exe'
|
|
19
|
+
if p.is_file(): binary = str(p)
|
|
20
|
+
running = hardware.installed(BASE) is not None
|
|
21
|
+
return {'system': system, 'arch': platform.machine(), 'state': 'running' if running else 'stopped' if binary else 'not_detected',
|
|
22
|
+
'binary': bool(binary), 'supported': system in SOURCES, 'source': SOURCES.get(system, ''), 'page': PAGES.get(system, ''),
|
|
23
|
+
'disk_free_gb': round(shutil.disk_usage(tempfile.gettempdir()).free / 1024 ** 3, 1),
|
|
24
|
+
'host_note': 'The computer running Chatify, not the browser or phone.', '_binary': binary}
|
|
25
|
+
|
|
26
|
+
class OfficialRedirect(urllib.request.HTTPRedirectHandler):
|
|
27
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
28
|
+
if urlparse(newurl).scheme != 'https' or urlparse(newurl).hostname not in ('ollama.com', 'github.com', 'objects.githubusercontent.com', 'release-assets.githubusercontent.com'):
|
|
29
|
+
raise ValueError('Installer redirected outside the official download hosts')
|
|
30
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
31
|
+
|
|
32
|
+
def download(url, folder, cancelled=None, progress=None):
|
|
33
|
+
if url not in SOURCES.values(): raise ValueError('Unknown installer source')
|
|
34
|
+
target = Path(folder) / url.rsplit('/', 1)[1]
|
|
35
|
+
opener = urllib.request.build_opener(OfficialRedirect)
|
|
36
|
+
with opener.open(url, timeout=60) as response, target.open('wb') as out:
|
|
37
|
+
total = int(response.headers.get('Content-Length') or 0)
|
|
38
|
+
if total > LIMIT: raise ValueError('Installer exceeds the 2 GB download limit')
|
|
39
|
+
used = 0
|
|
40
|
+
while True:
|
|
41
|
+
if cancelled and cancelled.is_set(): raise ValueError('Download cancelled')
|
|
42
|
+
chunk = response.read(1024 * 1024)
|
|
43
|
+
if not chunk: break
|
|
44
|
+
used += len(chunk)
|
|
45
|
+
if used > LIMIT: raise ValueError('Installer exceeds the 2 GB download limit')
|
|
46
|
+
out.write(chunk)
|
|
47
|
+
if progress:progress(used,total)
|
|
48
|
+
if not used: raise ValueError('Empty installer download')
|
|
49
|
+
return target
|
|
50
|
+
|
|
51
|
+
class OllamaSetup:
|
|
52
|
+
def __init__(self):
|
|
53
|
+
self.lock = threading.Lock(); self.pending = {}; self.jobs = {}; self.processes = []; self.cancel_events = {}; self.job_processes = {}
|
|
54
|
+
def status(self):
|
|
55
|
+
data = detect(); data.pop('_binary', None)
|
|
56
|
+
return data
|
|
57
|
+
def prepare(self, action):
|
|
58
|
+
if action not in ('install', 'start'): raise ValueError('Choose install or start')
|
|
59
|
+
data = detect(); data.pop('_binary', None)
|
|
60
|
+
if not data['supported']: raise ValueError('Use the official Ollama installer on this platform')
|
|
61
|
+
if action == 'install' and data['state'] != 'not_detected': raise ValueError('Ollama is already detected. Start it or recheck instead.')
|
|
62
|
+
if action == 'start' and data['state'] != 'stopped': raise ValueError('Start is available only for a detected, stopped Ollama')
|
|
63
|
+
if action == 'install' and data['disk_free_gb'] < 5: raise ValueError('At least 5 GB free disk space is required before installation. Models need more.')
|
|
64
|
+
ticket = secrets.token_urlsafe(24)
|
|
65
|
+
with self.lock:
|
|
66
|
+
self.pending = {ticket: (action, time.monotonic(), data['system'])}
|
|
67
|
+
data.update(ticket=ticket, action=action, download_size='Not known until download; limit 2 GB',
|
|
68
|
+
changes='Install Ollama on this host. OS installer may require local interaction or permission. No models or cloud accounts added.' if action == 'install' else 'Start the detected local Ollama service.',
|
|
69
|
+
instructions={'Darwin': 'In the disk image, drag Ollama to Applications, then open it. Approve the CLI link only in the local OS prompt.',
|
|
70
|
+
'Windows': 'Complete the official installer on this computer. Ollama should run in the background.',
|
|
71
|
+
'Linux': 'The official script may require sudo. Chatify never asks for a password. If permission is needed, follow the official page in a local terminal.'}[data['system']])
|
|
72
|
+
return data
|
|
73
|
+
def execute(self, ticket, confirmed):
|
|
74
|
+
if confirmed is not True: raise ValueError('Review and confirm this installation first')
|
|
75
|
+
with self.lock:
|
|
76
|
+
item = self.pending.pop(ticket, None)
|
|
77
|
+
if not item or time.monotonic() - item[1] > 600: raise ValueError('Review the installation again; approval expired')
|
|
78
|
+
if any(x['state'] in ('downloading', 'starting', 'waiting') for x in self.jobs.values()): raise ValueError('A setup is already in progress')
|
|
79
|
+
action, _, system = item
|
|
80
|
+
if system != platform.system(): raise ValueError('Host changed; review again')
|
|
81
|
+
current = detect()
|
|
82
|
+
if action == 'install' and current['state'] != 'not_detected': raise ValueError('Ollama is now detected; recheck instead of installing again')
|
|
83
|
+
if action == 'start' and current['state'] != 'stopped': raise ValueError('Service state changed; recheck')
|
|
84
|
+
jid = secrets.token_urlsafe(18)
|
|
85
|
+
self.cancel_events[jid]=threading.Event()
|
|
86
|
+
self.jobs = {jid: {'id': jid, 'state': 'starting' if action == 'start' else 'downloading', 'message': 'Starting Ollama' if action == 'start' else 'Downloading the official installer', 'action': action}}
|
|
87
|
+
threading.Thread(target=self._work, args=(jid, action, system), daemon=True).start()
|
|
88
|
+
return {'id': jid}
|
|
89
|
+
def job(self, jid):
|
|
90
|
+
with self.lock: result = dict(self.jobs.get(jid, {}))
|
|
91
|
+
if not result: raise ValueError('Unknown setup')
|
|
92
|
+
return result
|
|
93
|
+
def _update(self, jid, **values):
|
|
94
|
+
with self.lock:
|
|
95
|
+
if self.jobs[jid]['state']!='cancelled':self.jobs[jid].update(values)
|
|
96
|
+
def cancel(self, jid):
|
|
97
|
+
with self.lock:
|
|
98
|
+
job=self.jobs.get(jid)
|
|
99
|
+
if not job:raise ValueError('Unknown setup')
|
|
100
|
+
if job['state'] in ('ready','failed','needs_manual','cancelled'):return dict(job)
|
|
101
|
+
self.cancel_events[jid].set()
|
|
102
|
+
proc=self.job_processes.get(jid)
|
|
103
|
+
if proc and proc.poll() is None:
|
|
104
|
+
try:proc.terminate()
|
|
105
|
+
except OSError:pass
|
|
106
|
+
job.update(state='cancelled',message='Chatify stopped its setup work. An OS wizard or child installer may still be open; close it on the host. Installed changes are not rolled back. Retry requires a new review; installer downloads restart, not resume.')
|
|
107
|
+
return dict(job)
|
|
108
|
+
def _work(self, jid, action, system):
|
|
109
|
+
try:
|
|
110
|
+
if action == 'start':
|
|
111
|
+
binary = detect().get('_binary')
|
|
112
|
+
if not binary: raise ValueError('Ollama executable no longer detected; recheck')
|
|
113
|
+
cmd = [binary, 'serve']
|
|
114
|
+
else:
|
|
115
|
+
# Keep mounted disk images available until the OS wizard is finished.
|
|
116
|
+
folder = tempfile.mkdtemp(prefix='chatify-ollama-')
|
|
117
|
+
target = download(SOURCES[system], folder, self.cancel_events.get(jid), lambda used,total:self._update(jid,downloaded_bytes=used,total_bytes=total,progress_pct=round(100*used/total) if total else None))
|
|
118
|
+
digestor = hashlib.sha256()
|
|
119
|
+
with target.open('rb') as downloaded:
|
|
120
|
+
for chunk in iter(lambda: downloaded.read(1024 * 1024), b''): digestor.update(chunk)
|
|
121
|
+
digest = digestor.hexdigest()
|
|
122
|
+
self._update(jid, state='starting', message='Opening the official installer on this computer', sha256=digest)
|
|
123
|
+
cmd = [str(target)] if system == 'Windows' else ['open', str(target)] if system == 'Darwin' else ['sh', str(target)]
|
|
124
|
+
with self.lock:
|
|
125
|
+
if self.cancel_events.get(jid) and self.cancel_events[jid].is_set(): return
|
|
126
|
+
proc = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
127
|
+
self.processes.append(proc);self.job_processes[jid]=proc
|
|
128
|
+
self._update(jid, state='waiting', message='Complete any OS installer or permission prompt on the host, then recheck. No password is collected here.')
|
|
129
|
+
for _ in range(300):
|
|
130
|
+
if self.cancel_events.get(jid) and self.cancel_events[jid].is_set():return
|
|
131
|
+
if hardware.installed(BASE) is not None:
|
|
132
|
+
self._update(jid, state='ready', message='Local Ollama service verified. Choose a model separately.'); return
|
|
133
|
+
if system == 'Linux' and proc.poll() not in (None, 0):
|
|
134
|
+
self._update(jid, state='needs_manual', message='Installer could not complete non-interactively. Use the official install page in a local terminal; then recheck.', page=PAGES[system]); return
|
|
135
|
+
time.sleep(2)
|
|
136
|
+
self._update(jid, state='needs_manual', message='Local service is not reachable yet. Complete the OS setup or use the official instructions, then recheck.', page=PAGES[system])
|
|
137
|
+
except Exception:
|
|
138
|
+
self._update(jid, state='failed', message='Setup could not complete. No success claimed. Use the official install page, then recheck.', page=PAGES.get(system, 'https://ollama.com'))
|
chatify/pdfread.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Pull plain text out of a PDF. Uses pypdf when installed; otherwise a small built-in reader
|
|
2
|
+
that handles ordinary text PDFs (Flate-compressed streams with Tj/TJ text operators).
|
|
3
|
+
Scanned PDFs (images only) have no text to extract and raise a clear error."""
|
|
4
|
+
import re, zlib
|
|
5
|
+
|
|
6
|
+
MAX_BYTES = 8_000_000
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _unescape(s):
|
|
10
|
+
s = re.sub(rb"\\([nrtbf()\\])", lambda m: {b"n": b"\n", b"r": b"\r", b"t": b"\t", b"b": b"", b"f": b""}.get(m.group(1), m.group(1)), s)
|
|
11
|
+
return re.sub(rb"\\([0-7]{1,3})", lambda m: bytes([int(m.group(1), 8) & 255]), s)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _builtin(data):
|
|
15
|
+
out = []
|
|
16
|
+
for m in re.finditer(rb"stream\r?\n(.*?)\r?\nendstream", data, re.S):
|
|
17
|
+
raw = m.group(1)
|
|
18
|
+
try:
|
|
19
|
+
raw = zlib.decompress(raw)
|
|
20
|
+
except Exception:
|
|
21
|
+
pass
|
|
22
|
+
if b"BT" not in raw:
|
|
23
|
+
continue
|
|
24
|
+
for blk in re.findall(rb"BT(.*?)ET", raw, re.S):
|
|
25
|
+
for arr in re.finditer(rb"\[(.*?)\]\s*TJ|\((.*?)(?<!\\)\)\s*(?:Tj|'|\")", blk, re.S):
|
|
26
|
+
line = []
|
|
27
|
+
if arr.group(1) is not None:
|
|
28
|
+
parts = re.findall(rb"\(((?:\\.|[^\\)])*)\)|(-?\d+\.?\d*)", arr.group(1))
|
|
29
|
+
for t, k in parts:
|
|
30
|
+
if t or not k:
|
|
31
|
+
line.append(_unescape(t).decode("latin-1"))
|
|
32
|
+
elif float(k) < -200:
|
|
33
|
+
line.append(" ")
|
|
34
|
+
else:
|
|
35
|
+
line.append(_unescape(arr.group(2)).decode("latin-1"))
|
|
36
|
+
if line:
|
|
37
|
+
out.append("".join(line))
|
|
38
|
+
return "\n".join(out)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def extract(data):
|
|
42
|
+
if not data.startswith(b"%PDF"):
|
|
43
|
+
raise ValueError("That file is not a PDF")
|
|
44
|
+
if len(data) > MAX_BYTES:
|
|
45
|
+
raise ValueError("PDF is over 8 MB")
|
|
46
|
+
text = ""
|
|
47
|
+
try:
|
|
48
|
+
import io
|
|
49
|
+
from pypdf import PdfReader
|
|
50
|
+
text = "\n\n".join((p.extract_text() or "") for p in PdfReader(io.BytesIO(data)).pages)
|
|
51
|
+
except ImportError:
|
|
52
|
+
text = _builtin(data)
|
|
53
|
+
except Exception:
|
|
54
|
+
text = _builtin(data)
|
|
55
|
+
text = re.sub(r"[ \t]+", " ", text).strip()
|
|
56
|
+
if len(text) < 20:
|
|
57
|
+
raise ValueError("No readable text in this PDF (it may be a scan)")
|
|
58
|
+
return text[:150_000]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def pages(data, ocr=False, max_pages=100):
|
|
62
|
+
"""Page-preserving extraction; OCR is explicit and local, never a web upload."""
|
|
63
|
+
if not data.startswith(b"%PDF") or len(data) > MAX_BYTES:
|
|
64
|
+
raise ValueError("PDF must be valid and at most 8 MB")
|
|
65
|
+
try:
|
|
66
|
+
import io
|
|
67
|
+
from pypdf import PdfReader
|
|
68
|
+
except ImportError:
|
|
69
|
+
raise ValueError("Page anchors need pypdf. Install chatify[documents].") from None
|
|
70
|
+
reader = PdfReader(io.BytesIO(data))
|
|
71
|
+
if len(reader.pages) > max_pages:
|
|
72
|
+
raise ValueError("PDF exceeds the 100-page local extraction limit")
|
|
73
|
+
out = []
|
|
74
|
+
for number, page in enumerate(reader.pages, 1):
|
|
75
|
+
text = (page.extract_text() or "").strip()
|
|
76
|
+
if len(text) < 20 and ocr:
|
|
77
|
+
text = _ocr_page(data, number)
|
|
78
|
+
if text: out.append({"page": number, "text": text[:150000], "ocr": len((page.extract_text() or "").strip()) < 20 and ocr})
|
|
79
|
+
if not out: raise ValueError("No readable text. Set ocr: true and install local OCR tools for scans.")
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _ocr_page(data, number):
|
|
84
|
+
import shutil, subprocess, tempfile
|
|
85
|
+
from pathlib import Path
|
|
86
|
+
if not shutil.which("pdftoppm") or not shutil.which("tesseract"):
|
|
87
|
+
raise ValueError("OCR needs pdftoppm (Poppler) and Tesseract installed on this computer")
|
|
88
|
+
with tempfile.TemporaryDirectory(prefix="chatify-ocr-") as folder:
|
|
89
|
+
root = Path(folder); source = root / "document.pdf"; source.write_bytes(data)
|
|
90
|
+
try:
|
|
91
|
+
subprocess.run(["pdftoppm", "-f", str(number), "-l", str(number), "-scale-to", "2000", "-singlefile", "-png", str(source), str(root / "page")],
|
|
92
|
+
check=True, timeout=30, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
93
|
+
result = subprocess.run(["tesseract", str(root / "page.png"), "stdout"], check=True, timeout=30,
|
|
94
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
95
|
+
except (subprocess.SubprocessError, OSError):
|
|
96
|
+
raise ValueError("Local OCR failed or timed out; no text from that page was indexed") from None
|
|
97
|
+
return result.stdout.decode("utf-8", errors="replace").strip()
|
chatify/permissions.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Explicit source/document readers. Missing policy is public, empty readers deny all."""
|
|
2
|
+
import copy
|
|
3
|
+
|
|
4
|
+
def restricted(cfg):return any('read_users' in s or s.get('document_users') for s in cfg['sources'])
|
|
5
|
+
def source(cfg,sid):return next((s for s in cfg['sources'] if s['id']==sid),None)
|
|
6
|
+
def allowed(cfg,owner,sid,document=None):
|
|
7
|
+
s=source(cfg,sid)
|
|
8
|
+
if s is None:return not restricted(cfg) or sid=='uploads' # own uploads are retrieved through the owner store only
|
|
9
|
+
if 'read_users' in s and owner not in s['read_users']:return False
|
|
10
|
+
rules=s.get('document_users',{})
|
|
11
|
+
return document not in rules or owner in rules[document]
|
|
12
|
+
def documents(cfg,owner,sid,conn):
|
|
13
|
+
if not allowed(cfg,owner,sid):return []
|
|
14
|
+
s=source(cfg,sid)
|
|
15
|
+
if not s or not s.get('document_users'):return None
|
|
16
|
+
return sorted({d.get('document') for d in conn.docs if d.get('document') and allowed(cfg,owner,sid,d['document'])})
|
|
17
|
+
def view(cfg,owner,row):
|
|
18
|
+
row=copy.deepcopy(row)
|
|
19
|
+
if any(not allowed(cfg,owner,e.get('source'),e.get('document')) for e in row.get('evidence',[])):
|
|
20
|
+
row.update(answer='This saved answer is no longer available under your document permissions.',evidence=[],ledger=[],standalone='',followups=[],scope=None)
|
|
21
|
+
return row
|