customchat-app 0.1.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.
- customchat/__init__.py +2 -0
- customchat/__main__.py +2 -0
- customchat/accounts.py +95 -0
- customchat/actions.py +31 -0
- customchat/budget.py +74 -0
- customchat/cli.py +136 -0
- customchat/configchange.py +46 -0
- customchat/configdoctor.py +106 -0
- customchat/connectors/__init__.py +6 -0
- customchat/connectors/arxiv.py +28 -0
- customchat/connectors/base.py +46 -0
- customchat/connectors/http_json.py +46 -0
- customchat/connectors/local_files.py +188 -0
- customchat/connectors/open_sources.py +105 -0
- customchat/connectors/pubmed.py +60 -0
- customchat/connectors/web_search.py +42 -0
- customchat/exports.py +96 -0
- customchat/fetch.py +75 -0
- customchat/generators.py +334 -0
- customchat/hardware.py +217 -0
- customchat/launcher.py +43 -0
- customchat/mysqlstore.py +88 -0
- customchat/pdfread.py +97 -0
- customchat/permissions.py +21 -0
- customchat/pipeline.py +642 -0
- customchat/portable.py +128 -0
- customchat/prompts.py +137 -0
- customchat/providers.py +412 -0
- customchat/schema.py +200 -0
- customchat/secrets.py +64 -0
- customchat/server.py +603 -0
- customchat/store.py +332 -0
- customchat/suggestions.py +47 -0
- customchat/templates/minimal/app.yaml +16 -0
- customchat/templates/minimal/docs/about.md +7 -0
- customchat/terminal.py +15 -0
- customchat/theme.py +140 -0
- customchat/web/app.js +633 -0
- customchat/web/brand.js +37 -0
- customchat/web/codeeditor.LICENSE.txt +21 -0
- customchat/web/codeeditor.js +14 -0
- customchat/web/customselect.css +14 -0
- customchat/web/customselect.js +38 -0
- customchat/web/error.html +10 -0
- customchat/web/index.html +57 -0
- customchat/web/motion.css +240 -0
- customchat/web/motion.js +145 -0
- customchat/web/panels.js +142 -0
- customchat/web/pipeline.js +93 -0
- customchat/web/reading.js +38 -0
- customchat/web/settings.css +723 -0
- customchat/web/settings.html +88 -0
- customchat/web/settings.js +566 -0
- customchat/web/style.css +655 -0
- customchat/web/theme.js +116 -0
- customchat/websearch.py +113 -0
- customchat_app-0.1.0.dist-info/METADATA +100 -0
- customchat_app-0.1.0.dist-info/RECORD +62 -0
- customchat_app-0.1.0.dist-info/WHEEL +5 -0
- customchat_app-0.1.0.dist-info/entry_points.txt +3 -0
- customchat_app-0.1.0.dist-info/licenses/LICENSE +21 -0
- customchat_app-0.1.0.dist-info/top_level.txt +1 -0
customchat/__init__.py
ADDED
customchat/__main__.py
ADDED
customchat/accounts.py
ADDED
|
@@ -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)
|
customchat/actions.py
ADDED
|
@@ -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')
|
customchat/budget.py
ADDED
|
@@ -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)
|
customchat/cli.py
ADDED
|
@@ -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)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Offline configuration diagnostics and editor schema. Never runs connectors."""
|
|
2
|
+
import copy, difflib, json, re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from . import schema
|
|
5
|
+
|
|
6
|
+
SOURCE_KEYS = {'read_users','document_users','id','label','type','weight','path','semantic_model','rerank_model','ocr','refresh_interval','url','results_path','fields','header_env','timeout','name','filter','category','entry','providers','provider','mailto','limit','language','endpoint','api_key_env'}
|
|
7
|
+
|
|
8
|
+
def editor_schema():
|
|
9
|
+
def node(v):
|
|
10
|
+
if isinstance(v, dict): return {'type':'object','properties':{k:node(x) for k,x in v.items()},'additionalProperties':False}
|
|
11
|
+
if isinstance(v, bool): return {'type':'boolean'}
|
|
12
|
+
if isinstance(v, int): return {'type':'integer'}
|
|
13
|
+
if isinstance(v, float): return {'type':'number'}
|
|
14
|
+
if isinstance(v, list): return {'type':'array','items':{'type':'string'}}
|
|
15
|
+
if v is None: return {}
|
|
16
|
+
return {'type':'string'}
|
|
17
|
+
out=node(schema.DEFAULTS);out.update({'$schema':'https://json-schema.org/draft/2020-12/schema','title':'CustomChat app configuration'})
|
|
18
|
+
props=out['properties'];props['schema_version']['const']=1
|
|
19
|
+
props['actions']['properties']['allow']={'type':['array','null'],'items':{'type':'string','enum':['list_notes','save_note']}}
|
|
20
|
+
p=props['provider']['properties'];p['type']['enum']=sorted(schema.PROVIDERS)
|
|
21
|
+
fallback=copy.deepcopy(p);fallback.pop('fallback',None)
|
|
22
|
+
p['fallback']={'anyOf':[{'type':'null'}, {'type':'object','properties':fallback,'additionalProperties':False}]}
|
|
23
|
+
props['auth']['properties']['mode']['enum']=sorted(schema.AUTH_MODES)
|
|
24
|
+
props['app']['properties']['theme']['enum']=['auto','light','dark']
|
|
25
|
+
props['prompt']['properties']['style']={'type':'object','additionalProperties':{'type':'string'}}
|
|
26
|
+
props['sources']={'type':'array','items':{'type':'object','required':['type'],'properties':{k:{} for k in sorted(SOURCE_KEYS)},'additionalProperties':True}}
|
|
27
|
+
sp=props['sources']['items']['properties'];sp['type']={'type':'string','enum':sorted(schema.CONNECTORS)}
|
|
28
|
+
for k in ('id','label','path','url','semantic_model','rerank_model','entry'):sp[k]={'type':'string'}
|
|
29
|
+
sp['ocr']={'type':'boolean'};sp['weight']={'type':'number','exclusiveMinimum':0}
|
|
30
|
+
props['retrieval']['properties']['top_k'].update(minimum=1,maximum=50)
|
|
31
|
+
props['server']['properties']['port'].update(minimum=1,maximum=65535)
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
def inspect(path):
|
|
35
|
+
"""Return issues and validated config; never echoes scalar values or reads .env."""
|
|
36
|
+
import yaml
|
|
37
|
+
text=Path(path).read_text(encoding='utf-8');issues=[];positions={};duplicates=[]
|
|
38
|
+
def walk(n, key=''):
|
|
39
|
+
positions[key]=(n.start_mark.line+1,n.start_mark.column+1)
|
|
40
|
+
if isinstance(n,yaml.MappingNode):
|
|
41
|
+
seen=set()
|
|
42
|
+
for k,v in n.value:
|
|
43
|
+
child=(key+'.' if key else '')+str(k.value)
|
|
44
|
+
positions[child]=(k.start_mark.line+1,k.start_mark.column+1)
|
|
45
|
+
if k.value in seen:duplicates.append(child)
|
|
46
|
+
seen.add(k.value);walk(v,child)
|
|
47
|
+
positions[child]=(k.start_mark.line+1,k.start_mark.column+1)
|
|
48
|
+
elif isinstance(n,yaml.SequenceNode):
|
|
49
|
+
for i,v in enumerate(n.value):walk(v,key+'['+str(i)+']')
|
|
50
|
+
def issue(key,msg,fix=''):
|
|
51
|
+
line,col=positions.get(key,positions.get(key.split('.')[0],(1,1)))
|
|
52
|
+
issues.append({'key':key or '(root)','line':line,'column':col,'message':msg,'suggestion':fix})
|
|
53
|
+
try:
|
|
54
|
+
n=yaml.compose(text)
|
|
55
|
+
if n:walk(n)
|
|
56
|
+
raw=yaml.safe_load(text) if not str(path).endswith('.json') else json.loads(text)
|
|
57
|
+
if raw is None:raw={}
|
|
58
|
+
except (yaml.YAMLError,json.JSONDecodeError) as e:
|
|
59
|
+
mark=getattr(e,'problem_mark',None)
|
|
60
|
+
issues.append({'key':'(syntax)','line':mark.line+1 if mark else getattr(e,'lineno',1),'column':mark.column+1 if mark else getattr(e,'colno',1),'message':'Invalid YAML/JSON syntax.','suggestion':'Check indentation, colons and quoted strings near this line.'})
|
|
61
|
+
return issues,None
|
|
62
|
+
for key in duplicates:issue(key,'Duplicate key; only the last value would be used.','Keep one entry for this key.')
|
|
63
|
+
def check(v, spec, key=''):
|
|
64
|
+
if 'anyOf' in spec:
|
|
65
|
+
if v is None:return
|
|
66
|
+
spec=next(x for x in spec['anyOf'] if x.get('type')=='object')
|
|
67
|
+
typ=spec.get('type');types={'object':dict,'array':list,'string':str,'integer':int,'number':(int,float),'boolean':bool}
|
|
68
|
+
if typ in types and (not isinstance(v,types[typ]) or typ in ('integer','number') and isinstance(v,bool)):
|
|
69
|
+
issue(key,'Expected '+typ+'.','Use a '+typ+' value.');return
|
|
70
|
+
if 'enum' in spec and v not in spec['enum']:issue(key,'Unsupported choice.','Choose: '+', '.join(spec['enum']))
|
|
71
|
+
for op in ('minimum','maximum','exclusiveMinimum'):
|
|
72
|
+
if op in spec and (v<spec[op] if op=='minimum' else v>spec[op] if op=='maximum' else v<=spec[op]):issue(key,'Number outside allowed range.','Use '+op+' '+str(spec[op])+'.')
|
|
73
|
+
if isinstance(v,dict):
|
|
74
|
+
known=spec.get('properties',{})
|
|
75
|
+
for k,x in v.items():
|
|
76
|
+
child=(key+'.' if key else '')+str(k)
|
|
77
|
+
if k in known:check(x,known[k],child)
|
|
78
|
+
elif spec.get('additionalProperties') is False:
|
|
79
|
+
near=difflib.get_close_matches(str(k),known,n=1)
|
|
80
|
+
issue(child,'Unknown key.','Use '+near[0]+'.' if near else 'Remove this key or check the schema.')
|
|
81
|
+
elif isinstance(spec.get('additionalProperties'),dict):check(x,spec['additionalProperties'],child)
|
|
82
|
+
for k in spec.get('required',[]):
|
|
83
|
+
if k not in v:issue(key+'.'+k,'Required key is missing.','Add '+k+'.')
|
|
84
|
+
if isinstance(v,list):
|
|
85
|
+
for i,x in enumerate(v):check(x,spec.get('items',{}),key+'['+str(i)+']')
|
|
86
|
+
check(raw,editor_schema())
|
|
87
|
+
cfg=None
|
|
88
|
+
if not issues:
|
|
89
|
+
try:cfg=schema.validate(raw)
|
|
90
|
+
except (schema.ConfigError,TypeError,KeyError,AttributeError,ValueError) as e:
|
|
91
|
+
msg=str(e) if isinstance(e,schema.ConfigError) else 'Configuration shape is invalid.'
|
|
92
|
+
if msg.startswith('Duplicate source id'):msg='sources: Duplicate source id. Each source needs a unique id.'
|
|
93
|
+
key=next((k for k in positions if k and k in msg),'')
|
|
94
|
+
match=re.search(r'(sources\[\d+\](?:\.\w+)?|(?:provider|retrieval|auth|budget|app)\.\w+)',msg)
|
|
95
|
+
if match:key=match.group(0)
|
|
96
|
+
issue(key,msg,'Check this key against the editor schema.')
|
|
97
|
+
return issues,cfg
|
|
98
|
+
|
|
99
|
+
def report(path):
|
|
100
|
+
try:issues,cfg=inspect(path)
|
|
101
|
+
except OSError:
|
|
102
|
+
print('Cannot read config file. Check the path and permissions.');return False
|
|
103
|
+
if issues:
|
|
104
|
+
for i in issues:print('%s:%d:%d %s: %s %s' % (path,i['line'],i['column'],i['key'],i['message'],i['suggestion']))
|
|
105
|
+
else:print('Config OK: %d source(s). No connectors or models were called.' % len(cfg['sources']))
|
|
106
|
+
return not issues
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""arXiv Atom API (public). block.category narrows the search, e.g. eess.SP for signal processing."""
|
|
2
|
+
import urllib.parse, urllib.request
|
|
3
|
+
import xml.etree.ElementTree as ET
|
|
4
|
+
from .base import Evidence
|
|
5
|
+
|
|
6
|
+
NS = {"a": "http://www.w3.org/2005/Atom"}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Arxiv:
|
|
10
|
+
def __init__(self, block):
|
|
11
|
+
self.id, self.label, self.b = block["id"], block["label"], block
|
|
12
|
+
|
|
13
|
+
def search(self, query, k=6):
|
|
14
|
+
q = "all:" + urllib.parse.quote(query)
|
|
15
|
+
if self.b.get("category"):
|
|
16
|
+
q += "+AND+cat:" + urllib.parse.quote(self.b["category"])
|
|
17
|
+
url = "http://export.arxiv.org/api/query?search_query=%s&max_results=%d&sortBy=relevance" % (q, k)
|
|
18
|
+
with urllib.request.urlopen(url, timeout=25) as r:
|
|
19
|
+
root = ET.fromstring(r.read())
|
|
20
|
+
out = []
|
|
21
|
+
for i, e in enumerate(root.findall("a:entry", NS)):
|
|
22
|
+
link = e.findtext("a:id", "", NS)
|
|
23
|
+
out.append(Evidence(" ".join((e.findtext("a:title", "", NS) or "").split()),
|
|
24
|
+
" ".join((e.findtext("a:summary", "", NS) or "").split()), link,
|
|
25
|
+
[a.findtext("a:name", "", NS) for a in e.findall("a:author", NS)][:6],
|
|
26
|
+
(e.findtext("a:published", "", NS) or "")[:4], "arXiv", self.id, 1.0 / (i + 1),
|
|
27
|
+
"arXiv:" + link.rsplit("/", 1)[-1]))
|
|
28
|
+
return out
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import importlib, os, sys
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def Evidence(title="", text="", url="", authors=None, year="", venue="", source="", score=0.0, id=""):
|
|
5
|
+
return {"id": id or title[:60], "title": title, "text": text, "url": url, "authors": authors or [],
|
|
6
|
+
"year": str(year or ""), "venue": venue, "source": source, "score": float(score)}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def make_connector(block, base_dir="."):
|
|
10
|
+
t = block["type"]
|
|
11
|
+
if t == "local_files":
|
|
12
|
+
from .local_files import LocalFiles
|
|
13
|
+
return LocalFiles(block, base_dir)
|
|
14
|
+
if t == "http_json":
|
|
15
|
+
from .http_json import HttpJson
|
|
16
|
+
return HttpJson(block)
|
|
17
|
+
if t == "pubmed":
|
|
18
|
+
from .pubmed import PubMed
|
|
19
|
+
return PubMed(block)
|
|
20
|
+
if t == "arxiv":
|
|
21
|
+
from .arxiv import Arxiv
|
|
22
|
+
return Arxiv(block)
|
|
23
|
+
if t in ("wikipedia", "crossref", "openalex"):
|
|
24
|
+
from . import open_sources
|
|
25
|
+
return {"wikipedia": open_sources.Wikipedia, "crossref": open_sources.Crossref, "openalex": open_sources.OpenAlex}[t](block)
|
|
26
|
+
if t == "web_search":
|
|
27
|
+
from .web_search import WebSearch
|
|
28
|
+
return WebSearch(block)
|
|
29
|
+
if t == "python":
|
|
30
|
+
# plugin: module:function, function(query, k) -> list of Evidence dicts
|
|
31
|
+
mod, _, fn = block["entry"].partition(":")
|
|
32
|
+
if base_dir not in sys.path:
|
|
33
|
+
sys.path.insert(0, os.path.abspath(base_dir))
|
|
34
|
+
f = getattr(importlib.import_module(mod), fn)
|
|
35
|
+
|
|
36
|
+
class Plugin:
|
|
37
|
+
def __init__(self): self.id = block["id"]; self.label = block["label"]
|
|
38
|
+
def search(self, q, k):
|
|
39
|
+
out = []
|
|
40
|
+
for e in f(q, k):
|
|
41
|
+
e = {**Evidence(), **e}
|
|
42
|
+
e["source"] = self.id
|
|
43
|
+
out.append(e)
|
|
44
|
+
return out
|
|
45
|
+
return Plugin()
|
|
46
|
+
raise ValueError("Unknown connector " + t)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Generic REST connector: GET a URL template, map JSON fields to evidence. Covers most search APIs."""
|
|
2
|
+
import json, os, urllib.parse, urllib.request
|
|
3
|
+
from .base import Evidence
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def dig(obj, path):
|
|
7
|
+
for part in [p for p in (path or "").split(".") if p]:
|
|
8
|
+
if isinstance(obj, list):
|
|
9
|
+
try: obj = obj[int(part)]
|
|
10
|
+
except (ValueError, IndexError): return None
|
|
11
|
+
elif isinstance(obj, dict):
|
|
12
|
+
obj = obj.get(part)
|
|
13
|
+
else:
|
|
14
|
+
return None
|
|
15
|
+
return obj
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HttpJson:
|
|
19
|
+
"""block: url (with {query} and {k}), results_path, fields {title,text,url,authors,year,venue},
|
|
20
|
+
optional header_env: {Header-Name: ENV_VAR} so keys never live in the file."""
|
|
21
|
+
def __init__(self, block):
|
|
22
|
+
self.id, self.label, self.b = block["id"], block["label"], block
|
|
23
|
+
|
|
24
|
+
def search(self, query, k=6):
|
|
25
|
+
url = self.b["url"].replace("{query}", urllib.parse.quote(query)).replace("{k}", str(k))
|
|
26
|
+
headers = {"User-Agent": "customchat/0.1"}
|
|
27
|
+
for h, env in (self.b.get("header_env") or {}).items():
|
|
28
|
+
if os.environ.get(env):
|
|
29
|
+
headers[h] = os.environ[env]
|
|
30
|
+
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=self.b.get("timeout", 20)) as r:
|
|
31
|
+
raw = r.read().decode("utf-8", "replace")
|
|
32
|
+
try:
|
|
33
|
+
data = json.loads(raw)
|
|
34
|
+
except ValueError:
|
|
35
|
+
# Broken or non-JSON reply: keep it as one text passage so the model can still read it.
|
|
36
|
+
return [Evidence(self.b.get("name", "Source reply"), raw[:4000], url, [], "", "", self.id, 1.0)] if raw.strip() else []
|
|
37
|
+
f = self.b.get("fields", {})
|
|
38
|
+
out = []
|
|
39
|
+
for i, item in enumerate(dig(data, self.b.get("results_path", "")) or []):
|
|
40
|
+
a = dig(item, f.get("authors", "authors")) or []
|
|
41
|
+
if isinstance(a, list):
|
|
42
|
+
a = [x.get("name", "") if isinstance(x, dict) else str(x) for x in a]
|
|
43
|
+
out.append(Evidence(str(dig(item, f.get("title", "title")) or ""), str(dig(item, f.get("text", "abstract")) or ""),
|
|
44
|
+
str(dig(item, f.get("url", "url")) or ""), a, dig(item, f.get("year", "year")) or "",
|
|
45
|
+
str(dig(item, f.get("venue", "venue")) or ""), self.id, 1.0 / (i + 1)))
|
|
46
|
+
return out[:k]
|