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/configdoctor.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
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':'Chatify 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');typ=next((t for t in typ if t!='null'),'null') if isinstance(typ,list) and v is not None else ('null' if isinstance(typ,list) else typ);types={'object':dict,'array':list,'string':str,'integer':int,'number':(int,float),'boolean':bool}
|
|
68
|
+
if typ=='null' and v is None:return
|
|
69
|
+
if typ in types and (not isinstance(v,types[typ]) or typ in ('integer','number') and isinstance(v,bool)):
|
|
70
|
+
issue(key,'Expected '+typ+'.','Use a '+typ+' value.');return
|
|
71
|
+
if 'enum' in spec and v not in spec['enum']:issue(key,'Unsupported choice.','Choose: '+', '.join(spec['enum']))
|
|
72
|
+
for op in ('minimum','maximum','exclusiveMinimum'):
|
|
73
|
+
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])+'.')
|
|
74
|
+
if isinstance(v,dict):
|
|
75
|
+
known=spec.get('properties',{})
|
|
76
|
+
for k,x in v.items():
|
|
77
|
+
child=(key+'.' if key else '')+str(k)
|
|
78
|
+
if k in known:check(x,known[k],child)
|
|
79
|
+
elif spec.get('additionalProperties') is False:
|
|
80
|
+
near=difflib.get_close_matches(str(k),known,n=1)
|
|
81
|
+
issue(child,'Unknown key.','Use '+near[0]+'.' if near else 'Remove this key or check the schema.')
|
|
82
|
+
elif isinstance(spec.get('additionalProperties'),dict):check(x,spec['additionalProperties'],child)
|
|
83
|
+
for k in spec.get('required',[]):
|
|
84
|
+
if k not in v:issue(key+'.'+k,'Required key is missing.','Add '+k+'.')
|
|
85
|
+
if isinstance(v,list):
|
|
86
|
+
for i,x in enumerate(v):check(x,spec.get('items',{}),key+'['+str(i)+']')
|
|
87
|
+
check(raw,editor_schema())
|
|
88
|
+
cfg=None
|
|
89
|
+
if not issues:
|
|
90
|
+
try:cfg=schema.validate(raw)
|
|
91
|
+
except (schema.ConfigError,TypeError,KeyError,AttributeError,ValueError) as e:
|
|
92
|
+
msg=str(e) if isinstance(e,schema.ConfigError) else 'Configuration shape is invalid.'
|
|
93
|
+
if msg.startswith('Duplicate source id'):msg='sources: Duplicate source id. Each source needs a unique id.'
|
|
94
|
+
key=next((k for k in positions if k and k in msg),'')
|
|
95
|
+
match=re.search(r'(sources\[\d+\](?:\.\w+)?|(?:provider|retrieval|auth|budget|app)\.\w+)',msg)
|
|
96
|
+
if match:key=match.group(0)
|
|
97
|
+
issue(key,msg,'Check this key against the editor schema.')
|
|
98
|
+
return issues,cfg
|
|
99
|
+
|
|
100
|
+
def report(path):
|
|
101
|
+
try:issues,cfg=inspect(path)
|
|
102
|
+
except OSError:
|
|
103
|
+
print('Cannot read config file. Check the path and permissions.');return False
|
|
104
|
+
if issues:
|
|
105
|
+
for i in issues:print('%s:%d:%d %s: %s %s' % (path,i['line'],i['column'],i['key'],i['message'],i['suggestion']))
|
|
106
|
+
else:print('Config OK: %d source(s). No connectors or models were called.' % len(cfg['sources']))
|
|
107
|
+
return not issues
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""arXiv Atom API (public). block.category narrows the search, e.g. eess.SP for signal processing."""
|
|
2
|
+
import time, urllib.error, 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 = "https://export.arxiv.org/api/query?search_query=%s&max_results=%d&sortBy=relevance" % (q, k)
|
|
18
|
+
for attempt in range(3):
|
|
19
|
+
try:
|
|
20
|
+
with urllib.request.urlopen(url, timeout=25) as r:
|
|
21
|
+
root = ET.fromstring(r.read())
|
|
22
|
+
break
|
|
23
|
+
except (TimeoutError, urllib.error.URLError) as error:
|
|
24
|
+
if isinstance(error, urllib.error.HTTPError) and error.code not in (429, 500, 502, 503, 504):
|
|
25
|
+
raise
|
|
26
|
+
if attempt == 2:
|
|
27
|
+
raise
|
|
28
|
+
time.sleep(3 * (attempt + 1))
|
|
29
|
+
out = []
|
|
30
|
+
for i, e in enumerate(root.findall("a:entry", NS)):
|
|
31
|
+
link = e.findtext("a:id", "", NS)
|
|
32
|
+
out.append(Evidence(" ".join((e.findtext("a:title", "", NS) or "").split()),
|
|
33
|
+
" ".join((e.findtext("a:summary", "", NS) or "").split()), link,
|
|
34
|
+
[a.findtext("a:name", "", NS) for a in e.findall("a:author", NS)][:6],
|
|
35
|
+
(e.findtext("a:published", "", NS) or "")[:4], "arXiv", self.id, 1.0 / (i + 1),
|
|
36
|
+
"arXiv:" + link.rsplit("/", 1)[-1]))
|
|
37
|
+
return out
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
if block.get('execution') == 'bounded':
|
|
31
|
+
from .bounded import BoundedPlugin
|
|
32
|
+
return BoundedPlugin(block, base_dir)
|
|
33
|
+
# plugin: module:function, function(query, k) -> list of Evidence dicts
|
|
34
|
+
mod, _, fn = block["entry"].partition(":")
|
|
35
|
+
if base_dir not in sys.path:
|
|
36
|
+
sys.path.insert(0, os.path.abspath(base_dir))
|
|
37
|
+
f = getattr(importlib.import_module(mod), fn)
|
|
38
|
+
|
|
39
|
+
class Plugin:
|
|
40
|
+
def __init__(self): self.id = block["id"]; self.label = block["label"]
|
|
41
|
+
def search(self, q, k):
|
|
42
|
+
from .recovery import recover_evidence
|
|
43
|
+
return recover_evidence(f(q,k),self.id,k)
|
|
44
|
+
return Plugin()
|
|
45
|
+
raise ValueError("Unknown connector " + t)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Separate, bounded plugin process. NOT a security sandbox: host files/network remain accessible."""
|
|
2
|
+
import json,os,re,subprocess,sys,tempfile
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
SCRIPT = '''import importlib,json,sys,os
|
|
5
|
+
if os.name!='nt':
|
|
6
|
+
import resource
|
|
7
|
+
resource.setrlimit(resource.RLIMIT_FSIZE,(2097152,2097152))
|
|
8
|
+
resource.setrlimit(resource.RLIMIT_CPU,(20,20))
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
sys.path.insert(0,sys.argv[1])
|
|
11
|
+
m,f=sys.argv[2].split(':',1)
|
|
12
|
+
q,k=json.loads(sys.stdin.read())
|
|
13
|
+
x=getattr(importlib.import_module(m),f)(q,k)
|
|
14
|
+
if isinstance(x,list):x=x[:k]
|
|
15
|
+
Path(sys.argv[3]).write_text(json.dumps(x))
|
|
16
|
+
'''
|
|
17
|
+
class BoundedPlugin:
|
|
18
|
+
def __init__(self,block,base):self.id=block['id'];self.label=block['label'];self.entry=block['entry'];self.base=os.path.abspath(base);self.key_env=block.get("api_key_env", "")
|
|
19
|
+
def search(self,q,k):
|
|
20
|
+
from .base import Evidence
|
|
21
|
+
k=max(1,min(int(k),50))
|
|
22
|
+
with tempfile.TemporaryDirectory(prefix='chatify-plugin-') as folder:
|
|
23
|
+
result=Path(folder)/'result.json';log=Path(folder)/'output.log'
|
|
24
|
+
allowed={'PATH','HOME','USERPROFILE','SYSTEMROOT','WINDIR','TEMP','TMP','TMPDIR','LANG','LC_ALL','LD_LIBRARY_PATH'}
|
|
25
|
+
env={a:b for a,b in os.environ.items() if a.upper() in allowed}
|
|
26
|
+
if re.fullmatch(r'[A-Z][A-Z0-9_]{2,100}',self.key_env) and self.key_env in os.environ:env[self.key_env]=os.environ[self.key_env]
|
|
27
|
+
with log.open('wb') as out:
|
|
28
|
+
p=subprocess.Popen([sys.executable,'-I','-c',SCRIPT,self.base,self.entry,str(result)],stdin=subprocess.PIPE,stdout=out,stderr=out,env=env,cwd=folder,start_new_session=os.name!='nt')
|
|
29
|
+
p.stdin.write(json.dumps([str(q)[:2000],k]).encode());p.stdin.close()
|
|
30
|
+
import time
|
|
31
|
+
deadline=time.monotonic()+20
|
|
32
|
+
while p.poll() is None:
|
|
33
|
+
if time.monotonic()>deadline or log.stat().st_size>1024*1024 or (result.exists() and result.stat().st_size>2*1024*1024):
|
|
34
|
+
if os.name!='nt':
|
|
35
|
+
import signal
|
|
36
|
+
try:os.killpg(p.pid,signal.SIGKILL)
|
|
37
|
+
except ProcessLookupError:pass
|
|
38
|
+
else:p.kill()
|
|
39
|
+
p.wait();raise ValueError('Connector exceeded its time/output limit')
|
|
40
|
+
time.sleep(.05)
|
|
41
|
+
if p.returncode or not result.exists():raise ValueError('Connector failed in bounded mode; inspect its code and dependencies')
|
|
42
|
+
if result.stat().st_size>2*1024*1024:raise ValueError('Connector result exceeds 2 MB')
|
|
43
|
+
from .recovery import recover_evidence
|
|
44
|
+
return recover_evidence(json.loads(result.read_text()),self.id,k)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Generic REST connector: GET a URL template, map JSON fields to evidence. Covers most search APIs."""
|
|
2
|
+
import json, urllib.parse, urllib.request
|
|
3
|
+
from .base import Evidence
|
|
4
|
+
from .. import source_credentials
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def dig(obj, path):
|
|
8
|
+
for part in [p for p in (path or "").split(".") if p]:
|
|
9
|
+
if isinstance(obj, list):
|
|
10
|
+
try: obj = obj[int(part)]
|
|
11
|
+
except (ValueError, IndexError): return None
|
|
12
|
+
elif isinstance(obj, dict):
|
|
13
|
+
obj = obj.get(part)
|
|
14
|
+
else:
|
|
15
|
+
return None
|
|
16
|
+
return obj
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _NoCredentialRedirect(urllib.request.HTTPRedirectHandler):
|
|
20
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
21
|
+
raise ValueError('Credential-bearing HTTP sources do not follow redirects; review the configured address')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HttpJson:
|
|
25
|
+
"""block: url (with {query} and {k}), results_path, fields {title,text,url,authors,year,venue},
|
|
26
|
+
optional header_env: {Header-Name: ENV_VAR} so keys never live in the file."""
|
|
27
|
+
def __init__(self, block):
|
|
28
|
+
self.id, self.label, self.b = block["id"], block["label"], block
|
|
29
|
+
|
|
30
|
+
def search(self, query, k=6):
|
|
31
|
+
url = self.b["url"].replace("{query}", urllib.parse.quote(query)).replace("{k}", str(k))
|
|
32
|
+
headers = {"User-Agent": "chatify/0.1"}
|
|
33
|
+
if self.b.get("header_env"):source_credentials.declared({"sources":[self.b]})
|
|
34
|
+
for h, env in (self.b.get("header_env") or {}).items():
|
|
35
|
+
key = source_credentials.value(self.id, env, self.b["url"])
|
|
36
|
+
if not key: raise ValueError('Missing private HTTP source key: ' + env)
|
|
37
|
+
headers[h] = key
|
|
38
|
+
request = urllib.request.Request(url, headers=headers)
|
|
39
|
+
opener = urllib.request.build_opener(_NoCredentialRedirect()).open if self.b.get('header_env') else urllib.request.urlopen
|
|
40
|
+
with opener(request, timeout=self.b.get("timeout", 20)) as r:
|
|
41
|
+
raw = r.read(1500000).decode("utf-8", "replace")
|
|
42
|
+
try:
|
|
43
|
+
data = json.loads(raw)
|
|
44
|
+
except ValueError:
|
|
45
|
+
# Broken or non-JSON reply: keep it as one text passage so the model can still read it.
|
|
46
|
+
return [Evidence(self.b.get("name", "Source reply"), raw[:4000], url, [], "", "", self.id, 1.0)] if raw.strip() else []
|
|
47
|
+
if isinstance(data, dict) and (data.get('status') == 'error' or data.get('errors')):
|
|
48
|
+
raise ValueError('HTTP source rejected the query; check its key, quota and plan')
|
|
49
|
+
items = dig(data, self.b.get("results_path", "")) or []
|
|
50
|
+
if not isinstance(items, list) or not all(isinstance(item, dict) for item in items):
|
|
51
|
+
from .recovery import recover_evidence
|
|
52
|
+
recovered=recover_evidence(data,self.id,k)
|
|
53
|
+
if not recovered:raise ValueError('HTTP source result mapping has no usable evidence')
|
|
54
|
+
return recovered
|
|
55
|
+
f = self.b.get("fields", {})
|
|
56
|
+
out = []
|
|
57
|
+
for i, item in enumerate(items):
|
|
58
|
+
a = dig(item, f.get("authors", "authors")) or []
|
|
59
|
+
if isinstance(a, list):
|
|
60
|
+
a = [x.get("name", "") if isinstance(x, dict) else str(x) for x in a]
|
|
61
|
+
elif a: a = [str(a)]
|
|
62
|
+
else: a = []
|
|
63
|
+
out.append(Evidence(str(dig(item, f.get("title", "title")) or ""), str(dig(item, f.get("text", "abstract")) or ""),
|
|
64
|
+
str(dig(item, f.get("url", "url")) or ""), a, dig(item, f.get("year", "year")) or "",
|
|
65
|
+
str(dig(item, f.get("venue", "venue")) or ""), self.id, 1.0 / (i + 1)))
|
|
66
|
+
return out[:k]
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Search a folder of .md .txt .json .csv files with BM25. No network, no embeddings needed."""
|
|
2
|
+
import glob, hashlib, json, math, os, re, threading, time
|
|
3
|
+
from .base import Evidence
|
|
4
|
+
|
|
5
|
+
TOKEN = re.compile(r"[a-z0-9]{2,}")
|
|
6
|
+
STOP = set("the of and to in is are for on with that this as by an be or it at from was were which can".split())
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def tokens(s):
|
|
10
|
+
return [t for t in TOKEN.findall(s.lower()) if t not in STOP]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def chunks(text, size=900):
|
|
14
|
+
paras, cur, out = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()], "", []
|
|
15
|
+
for p in paras:
|
|
16
|
+
if cur and len(cur) + len(p) > size:
|
|
17
|
+
out.append(cur); cur = p
|
|
18
|
+
else:
|
|
19
|
+
cur = (cur + "\n\n" + p).strip()
|
|
20
|
+
if cur:
|
|
21
|
+
out.append(cur)
|
|
22
|
+
return out
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LocalFiles:
|
|
26
|
+
def __init__(self, block, base_dir="."):
|
|
27
|
+
self.id, self.label = block["id"], block["label"]
|
|
28
|
+
root = block.get("path", "docs")
|
|
29
|
+
root = root if os.path.isabs(root) else os.path.join(base_dir, root)
|
|
30
|
+
self.root = os.path.abspath(root)
|
|
31
|
+
self.semantic_model = block.get("semantic_model", "")
|
|
32
|
+
self.rerank_model = block.get("rerank_model", "")
|
|
33
|
+
self._semantic = self._reranker = None
|
|
34
|
+
self._vectors = None; self._vector_revision = None
|
|
35
|
+
self.ocr = bool(block.get("ocr", False))
|
|
36
|
+
self.refresh_interval = float(block.get("refresh_interval", 30))
|
|
37
|
+
self._lock = threading.RLock()
|
|
38
|
+
self._snapshot = None
|
|
39
|
+
self.checked_at = self.indexed_at = 0
|
|
40
|
+
self.last_error = ""
|
|
41
|
+
self.last_check = {}
|
|
42
|
+
self.revision = ""
|
|
43
|
+
self.docs = []; self.idf = {}; self.avg = 0
|
|
44
|
+
try: self.refresh(force=True)
|
|
45
|
+
except OSError: pass # Keep the app available; failed folders are excluded until recovery.
|
|
46
|
+
|
|
47
|
+
def _scan(self):
|
|
48
|
+
if os.path.realpath(self.root) != self.root:
|
|
49
|
+
raise OSError("Local document folder must not use symlinks")
|
|
50
|
+
if not os.path.isdir(self.root):
|
|
51
|
+
raise OSError("Local document folder is missing or unavailable")
|
|
52
|
+
rows = []
|
|
53
|
+
for p in sorted(glob.glob(os.path.join(self.root, "**", "*"), recursive=True)):
|
|
54
|
+
if not p.lower().endswith((".md", ".txt", ".json", ".csv", ".pdf")): continue
|
|
55
|
+
if os.path.islink(p) or os.path.realpath(p) != os.path.abspath(p):
|
|
56
|
+
raise OSError("Local documents must not use symlinks")
|
|
57
|
+
if os.path.isfile(p):
|
|
58
|
+
with open(p, "rb") as f: data = f.read()
|
|
59
|
+
rows.append((os.path.relpath(p, self.root), data, hashlib.sha256(data).hexdigest()))
|
|
60
|
+
return rows
|
|
61
|
+
|
|
62
|
+
def refresh(self, force=False):
|
|
63
|
+
"""Swap a complete content-hashed index only after a successful scan."""
|
|
64
|
+
with self._lock:
|
|
65
|
+
if not force and not self.last_error and time.time() - self.checked_at < self.refresh_interval:
|
|
66
|
+
return False
|
|
67
|
+
self.checked_at = time.time()
|
|
68
|
+
try:
|
|
69
|
+
rows = self._scan()
|
|
70
|
+
old = dict(self._snapshot or ())
|
|
71
|
+
current = {name:digest for name,_,digest in rows}
|
|
72
|
+
old_chunks = len(self.docs)
|
|
73
|
+
previous_passages = {(d["id"],d["text"]) for d in self.docs}
|
|
74
|
+
added = len(current.keys()-old.keys()); removed = len(old.keys()-current.keys())
|
|
75
|
+
changed = sum(old[name] != current[name] for name in old.keys() & current.keys())
|
|
76
|
+
signature = tuple((name, digest) for name, _, digest in rows)
|
|
77
|
+
if signature == self._snapshot:
|
|
78
|
+
self.last_check = {"state":"unchanged","documents_scanned":len(rows),"added":0,"removed":0,"changed":0,"passages_before":old_chunks,"passages_after":old_chunks,"passages_added":0,"passages_removed":0}
|
|
79
|
+
self.last_error = ""
|
|
80
|
+
return False
|
|
81
|
+
nxt = LocalFiles.__new__(LocalFiles)
|
|
82
|
+
nxt.docs = []
|
|
83
|
+
for name, data, digest in rows:
|
|
84
|
+
if name.lower().endswith(".pdf"):
|
|
85
|
+
from ..pdfread import pages
|
|
86
|
+
for page in pages(data, ocr=self.ocr):
|
|
87
|
+
nxt._add(os.path.join(self.root, name), self.root, page["text"].encode(), digest, page["page"], page["ocr"])
|
|
88
|
+
else:
|
|
89
|
+
nxt._add(os.path.join(self.root, name), self.root, data, digest)
|
|
90
|
+
n = len(nxt.docs) or 1
|
|
91
|
+
df = {}
|
|
92
|
+
for d in nxt.docs:
|
|
93
|
+
for t in set(d["tok"]): df[t] = df.get(t, 0) + 1
|
|
94
|
+
idf = {t: math.log(1 + (n - c + 0.5) / (c + 0.5)) for t, c in df.items()}
|
|
95
|
+
avg = sum(len(d["tok"]) for d in nxt.docs) / n
|
|
96
|
+
self.docs, self.idf, self.avg = nxt.docs, idf, avg
|
|
97
|
+
self._snapshot = signature
|
|
98
|
+
self.revision = hashlib.sha256(json.dumps(signature).encode()).hexdigest()
|
|
99
|
+
self.indexed_at = time.time(); self.last_error = ""
|
|
100
|
+
self.last_check = {"state":"updated","documents_scanned":len(rows),"added":added,"removed":removed,"changed":changed,"passages_before":old_chunks,"passages_after":len(self.docs),"passages_added":len({(d["id"],d["text"]) for d in self.docs}-previous_passages),"passages_removed":len(previous_passages-{(d["id"],d["text"]) for d in self.docs})}
|
|
101
|
+
return True
|
|
102
|
+
except (OSError, ValueError) as exc:
|
|
103
|
+
self.last_check = {"state":"failed"}
|
|
104
|
+
self.last_error = "Local folder check failed: "+str(exc)+". Check that the folder exists, files are readable and PDF dependencies are installed. No old passages will be used until it recovers."
|
|
105
|
+
raise OSError("Local source could not be indexed; check PDF dependencies or readability") from None
|
|
106
|
+
|
|
107
|
+
def status(self):
|
|
108
|
+
with self._lock:
|
|
109
|
+
return {"id": self.id, "label": self.label, "documents": len(self._snapshot or ()),
|
|
110
|
+
"chunks": len(self.docs), "revision": self.revision,
|
|
111
|
+
"checked_at": self.checked_at, "indexed_at": self.indexed_at,
|
|
112
|
+
"refresh_interval": self.refresh_interval, "error": self.last_error, "last_check":dict(self.last_check)}
|
|
113
|
+
|
|
114
|
+
def _add(self, path, root, data=None, digest="", page=None, ocr=False):
|
|
115
|
+
if data is None:
|
|
116
|
+
with open(path, "rb") as f: data = f.read()
|
|
117
|
+
raw = data.decode("utf-8", errors="replace")
|
|
118
|
+
digest = digest or hashlib.sha256(data).hexdigest()
|
|
119
|
+
title = os.path.splitext(os.path.relpath(path, root))[0]
|
|
120
|
+
m = re.match(r"\s*#\s+(.+)", raw)
|
|
121
|
+
if m:
|
|
122
|
+
title = m.group(1).strip()
|
|
123
|
+
if path.endswith(".json"):
|
|
124
|
+
try:
|
|
125
|
+
raw = json.dumps(json.loads(raw), indent=1)
|
|
126
|
+
except ValueError:
|
|
127
|
+
pass
|
|
128
|
+
sections = re.split(r"(?m)^(?=#{1,3}\s)", raw) if raw.lstrip().startswith("#") or "\n#" in raw else [raw]
|
|
129
|
+
n = 0
|
|
130
|
+
for sec in sections:
|
|
131
|
+
if not sec.strip():
|
|
132
|
+
continue
|
|
133
|
+
h = re.match(r"\s*#{1,3}\s+(.+)", sec)
|
|
134
|
+
sec_title = h.group(1).strip() if h else title
|
|
135
|
+
body = re.sub(r"^\s*#{1,3}\s+.*\n?", "", sec, count=1) if h else sec
|
|
136
|
+
for c in chunks(body):
|
|
137
|
+
self.docs.append({"title": sec_title, "text": c, "tok": tokens(sec_title + " " + c),
|
|
138
|
+
"id": "%s#%s%d" % (os.path.relpath(path, root), ("page-%d-" % page) if page else "", n), "document": os.path.relpath(path, root), "version": digest, "section": sec_title, "page": page, "ocr": ocr})
|
|
139
|
+
n += 1
|
|
140
|
+
|
|
141
|
+
def search(self, query, k=6, documents=None):
|
|
142
|
+
if hasattr(self, "_lock"):
|
|
143
|
+
with self._lock:
|
|
144
|
+
self.refresh()
|
|
145
|
+
return self._search(query, k, documents)
|
|
146
|
+
return self._search(query, k, documents)
|
|
147
|
+
|
|
148
|
+
def _search(self, query, k, documents=None):
|
|
149
|
+
q = tokens(query)
|
|
150
|
+
scored = []
|
|
151
|
+
for d in self.docs:
|
|
152
|
+
if documents is not None and d.get("document") not in documents: continue
|
|
153
|
+
tf = {}
|
|
154
|
+
for t in d["tok"]:
|
|
155
|
+
tf[t] = tf.get(t, 0) + 1
|
|
156
|
+
s = 0.0
|
|
157
|
+
for t in q:
|
|
158
|
+
if t in tf:
|
|
159
|
+
s += self.idf.get(t, 0) * tf[t] * 2.2 / (tf[t] + 1.2 * (0.25 + 0.75 * len(d["tok"]) / (self.avg or 1)))
|
|
160
|
+
if s > 0:
|
|
161
|
+
scored.append((s, d))
|
|
162
|
+
if getattr(self, "semantic_model", "") and self.docs:
|
|
163
|
+
if not self._semantic:
|
|
164
|
+
self._semantic = self._model(self.semantic_model, False)
|
|
165
|
+
if self._vector_revision != self.revision:
|
|
166
|
+
self._vectors = self._semantic.encode([d["title"] + " " + d["text"] for d in self.docs], normalize_embeddings=True)
|
|
167
|
+
self._vector_revision = self.revision
|
|
168
|
+
vector = self._semantic.encode([query], normalize_embeddings=True)[0]
|
|
169
|
+
semantic = sorted([(sum(float(a) * float(b) for a, b in zip(v, vector)), d) for v, d in zip(self._vectors, self.docs) if documents is None or d.get("document") in documents], key=lambda x: -x[0])
|
|
170
|
+
keyword = sorted(scored, key=lambda x: -x[0])
|
|
171
|
+
# Reciprocal rank fusion: do not compare unlike lexical/vector scales.
|
|
172
|
+
merged = {}
|
|
173
|
+
for ranking in (keyword, semantic):
|
|
174
|
+
for rank, (_, doc) in enumerate(ranking):
|
|
175
|
+
row = merged.setdefault(doc["id"], [0, doc]); row[0] += 1 / (60 + rank + 1)
|
|
176
|
+
scored = [tuple(v) for v in merged.values()]
|
|
177
|
+
scored.sort(key=lambda x: -x[0])
|
|
178
|
+
if documents is not None: scored = [(score, d) for score, d in scored if d.get("document") in documents]
|
|
179
|
+
if getattr(self, "rerank_model", "") and scored:
|
|
180
|
+
if not self._reranker: self._reranker = self._model(self.rerank_model, True)
|
|
181
|
+
candidate = scored[:min(30, max(k * 3, k))]
|
|
182
|
+
predictions = self._reranker.predict([(query, d["text"]) for _, d in candidate])
|
|
183
|
+
ranked = sorted([(float(value), d) for value, (_, d) in zip(predictions, candidate)], key=lambda x: -x[0])
|
|
184
|
+
scored = [(1.0 / (rank + 1), d) for rank, (_, d) in enumerate(ranked)]
|
|
185
|
+
return [{**Evidence(d["title"], d["text"], "", [], "", "", self.id, s, d["id"]),
|
|
186
|
+
**({"document": d["document"], "version": d["version"], "section": d["section"], "page": d["page"], "ocr": d["ocr"]} if "version" in d else {})} for s, d in scored[:k]]
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def _model(path, rerank):
|
|
190
|
+
if not os.path.isabs(path) or not os.path.isdir(path):
|
|
191
|
+
raise ValueError("Optional retrieval models must be existing absolute local directories")
|
|
192
|
+
try:
|
|
193
|
+
from sentence_transformers import SentenceTransformer, CrossEncoder
|
|
194
|
+
except ImportError:
|
|
195
|
+
raise ValueError("Optional semantic retrieval needs chatify[semantic]") from None
|
|
196
|
+
# Explicit local paths and local_files_only prevent network model downloads.
|
|
197
|
+
cls = CrossEncoder if rerank else SentenceTransformer
|
|
198
|
+
return cls(path, local_files_only=True, trust_remote_code=False)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Reference search sources: key-free Wikipedia/Crossref and keyed OpenAlex.
|
|
2
|
+
|
|
3
|
+
Shapes were checked against live responses on 5 Oct 2026. Each connector is tolerant of missing fields and
|
|
4
|
+
raises on network errors, which the pipeline reports as a source error without blocking other sources.
|
|
5
|
+
Crossref asks polite users to send a contact address; set `mailto` on the source block to do that.
|
|
6
|
+
OpenAlex keys come from the private store or OPENALEX_API_KEY. No key pauses searches.
|
|
7
|
+
"""
|
|
8
|
+
import json, os, re, time, urllib.parse, urllib.request, urllib.error
|
|
9
|
+
from .base import Evidence
|
|
10
|
+
|
|
11
|
+
UA = "chatify/1.0 (+https://github.com/Harsh23Kashyap/chatify)"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get(url, timeout=20):
|
|
15
|
+
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/json"})
|
|
16
|
+
for attempt in range(3):
|
|
17
|
+
try:
|
|
18
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
19
|
+
return json.loads(r.read(3000000).decode("utf-8", "replace"))
|
|
20
|
+
except urllib.error.HTTPError as error:
|
|
21
|
+
if attempt == 2 or error.code not in (429, 500, 502, 503, 504):
|
|
22
|
+
raise
|
|
23
|
+
time.sleep(attempt + 1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _clean(t):
|
|
27
|
+
return " ".join(re.sub(r"<[^>]+>", " ", str(t or "")).split())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Wikipedia:
|
|
31
|
+
def __init__(self, block):
|
|
32
|
+
self.id, self.label, self.b = block["id"], block["label"], block
|
|
33
|
+
self.lang = re.sub(r"[^a-z\-]", "", str(block.get("lang", "en")).lower()) or "en"
|
|
34
|
+
|
|
35
|
+
def search(self, query, k=6):
|
|
36
|
+
q = urllib.parse.urlencode({"action": "query", "generator": "search", "gsrsearch": query, "gsrlimit": k, "prop": "extracts",
|
|
37
|
+
"exintro": 1, "explaintext": 1, "exlimit": "max", "format": "json", "formatversion": 2, "redirects": 1})
|
|
38
|
+
data = _get("https://%s.wikipedia.org/w/api.php?%s" % (self.lang, q))
|
|
39
|
+
pages = (data.get("query") or {}).get("pages") or []
|
|
40
|
+
pages = sorted((p for p in pages if isinstance(p, dict)), key=lambda p: p.get("index", 99))
|
|
41
|
+
out = []
|
|
42
|
+
for n, p in enumerate(pages):
|
|
43
|
+
text = _clean(p.get("extract"))
|
|
44
|
+
title = _clean(p.get("title"))
|
|
45
|
+
if not (text and title):
|
|
46
|
+
continue
|
|
47
|
+
url = "https://%s.wikipedia.org/wiki/%s" % (self.lang, urllib.parse.quote(title.replace(" ", "_")))
|
|
48
|
+
out.append(Evidence(title, text[:1500], url, [], "", "Wikipedia", self.id, 1.0 / (n + 1), "wiki:" + str(p.get("pageid", title))))
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Crossref:
|
|
53
|
+
def __init__(self, block):
|
|
54
|
+
self.id, self.label, self.b = block["id"], block["label"], block
|
|
55
|
+
|
|
56
|
+
def search(self, query, k=6):
|
|
57
|
+
args = {"query": query, "rows": k, "select": "DOI,title,abstract,URL,issued,container-title,author"}
|
|
58
|
+
if self.b.get("mailto"):
|
|
59
|
+
args["mailto"] = str(self.b["mailto"])[:200]
|
|
60
|
+
data = _get("https://api.crossref.org/works?" + urllib.parse.urlencode(args))
|
|
61
|
+
items = (data.get("message") or {}).get("items") or []
|
|
62
|
+
out = []
|
|
63
|
+
for n, it in enumerate(items):
|
|
64
|
+
if not isinstance(it, dict):
|
|
65
|
+
continue
|
|
66
|
+
title = _clean((it.get("title") or [""])[0] if isinstance(it.get("title"), list) else it.get("title"))
|
|
67
|
+
text = _clean(it.get("abstract"))
|
|
68
|
+
if not title:
|
|
69
|
+
continue
|
|
70
|
+
year = ""
|
|
71
|
+
try:
|
|
72
|
+
year = str(it["issued"]["date-parts"][0][0] or "")
|
|
73
|
+
except (KeyError, IndexError, TypeError):
|
|
74
|
+
pass
|
|
75
|
+
venue = _clean((it.get("container-title") or [""])[0] if isinstance(it.get("container-title"), list) else "")
|
|
76
|
+
authors = [" ".join(x for x in (a.get("given"), a.get("family")) if x) for a in (it.get("author") or []) if isinstance(a, dict)][:6]
|
|
77
|
+
out.append(Evidence(title, (text or title)[:1500], it.get("URL") or "", authors, year, venue or "Crossref", self.id, 1.0 / (n + 1), "doi:" + str(it.get("DOI", title[:40]))))
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _abstract(inv):
|
|
82
|
+
if not isinstance(inv, dict):
|
|
83
|
+
return ""
|
|
84
|
+
words = {}
|
|
85
|
+
for w, pos in inv.items():
|
|
86
|
+
for p in pos if isinstance(pos, list) else []:
|
|
87
|
+
if isinstance(p, int):
|
|
88
|
+
words[p] = w
|
|
89
|
+
return " ".join(words[i] for i in sorted(words))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class OpenAlex:
|
|
93
|
+
def __init__(self, block):
|
|
94
|
+
self.id, self.label, self.b = block["id"], block["label"], block
|
|
95
|
+
|
|
96
|
+
def search(self, query, k=6):
|
|
97
|
+
args = {"search": re.sub(r"[?*]", " ", query).strip(), "per-page": k, "select": "id,title,publication_year,abstract_inverted_index,doi,authorships,primary_location"}
|
|
98
|
+
from .. import secrets
|
|
99
|
+
key = secrets.saved("catalog:openalex") or os.environ.get("OPENALEX_API_KEY", "")
|
|
100
|
+
if not key:
|
|
101
|
+
return []
|
|
102
|
+
if key:
|
|
103
|
+
args["api_key"] = key
|
|
104
|
+
data = _get("https://api.openalex.org/works?" + urllib.parse.urlencode(args))
|
|
105
|
+
out = []
|
|
106
|
+
for n, it in enumerate(data.get("results") or []):
|
|
107
|
+
if not isinstance(it, dict) or not it.get("title"):
|
|
108
|
+
continue
|
|
109
|
+
title = _clean(it["title"])
|
|
110
|
+
text = _abstract(it.get("abstract_inverted_index"))
|
|
111
|
+
authors = [((a.get("author") or {}).get("display_name") or "") for a in (it.get("authorships") or []) if isinstance(a, dict)][:6]
|
|
112
|
+
src = (((it.get("primary_location") or {}).get("source")) or {}).get("display_name") or "OpenAlex"
|
|
113
|
+
out.append(Evidence(title, (text or title)[:1500], it.get("doi") or it.get("id") or "", [a for a in authors if a], it.get("publication_year") or "", src, self.id, 1.0 / (n + 1), "openalex:" + str(it.get("id", title[:40]))))
|
|
114
|
+
return out
|