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.
Files changed (240) hide show
  1. chatify/__init__.py +2 -0
  2. chatify/__main__.py +2 -0
  3. chatify/accounts.py +95 -0
  4. chatify/actions.py +31 -0
  5. chatify/apipicks.py +45 -0
  6. chatify/appbuilder.py +260 -0
  7. chatify/budget.py +74 -0
  8. chatify/builddrafts.py +56 -0
  9. chatify/buildimages.py +34 -0
  10. chatify/buildjobs.py +263 -0
  11. chatify/bundle_review.py +39 -0
  12. chatify/cli.py +146 -0
  13. chatify/configchange.py +46 -0
  14. chatify/configdoctor.py +107 -0
  15. chatify/connectors/__init__.py +6 -0
  16. chatify/connectors/arxiv.py +37 -0
  17. chatify/connectors/base.py +45 -0
  18. chatify/connectors/bounded.py +44 -0
  19. chatify/connectors/http_json.py +66 -0
  20. chatify/connectors/local_files.py +198 -0
  21. chatify/connectors/open_sources.py +114 -0
  22. chatify/connectors/pubmed.py +60 -0
  23. chatify/connectors/recovery.py +34 -0
  24. chatify/connectors/web_search.py +43 -0
  25. chatify/draftaudit.py +20 -0
  26. chatify/editstate.py +64 -0
  27. chatify/exports.py +110 -0
  28. chatify/fetch.py +75 -0
  29. chatify/fileread.py +59 -0
  30. chatify/generator_design.py +94 -0
  31. chatify/generators.py +455 -0
  32. chatify/hardware.py +265 -0
  33. chatify/keybundle.py +45 -0
  34. chatify/launcher.py +46 -0
  35. chatify/management.py +161 -0
  36. chatify/modelcatalog.py +28 -0
  37. chatify/mysqlstore.py +88 -0
  38. chatify/nerdload.py +179 -0
  39. chatify/ollama_setup.py +138 -0
  40. chatify/pdfread.py +97 -0
  41. chatify/permissions.py +21 -0
  42. chatify/pipeline.py +863 -0
  43. chatify/portable.py +151 -0
  44. chatify/prompts.py +138 -0
  45. chatify/providers.py +482 -0
  46. chatify/readiness.py +33 -0
  47. chatify/schema.py +232 -0
  48. chatify/secrets.py +64 -0
  49. chatify/server.py +879 -0
  50. chatify/settingsguide.py +65 -0
  51. chatify/source_credentials.py +33 -0
  52. chatify/stage_contracts.py +24 -0
  53. chatify/store.py +412 -0
  54. chatify/suggestions.py +47 -0
  55. chatify/templates/minimal/app.yaml +16 -0
  56. chatify/templates/minimal/docs/about.md +7 -0
  57. chatify/terminal.py +15 -0
  58. chatify/theme.py +155 -0
  59. chatify/web/about.css +35 -0
  60. chatify/web/about.html +1 -0
  61. chatify/web/about.js +16 -0
  62. chatify/web/app.js +914 -0
  63. chatify/web/appbuilder.js +93 -0
  64. chatify/web/appearanceextras.js +13 -0
  65. chatify/web/brand.js +37 -0
  66. chatify/web/builderstudio.css +198 -0
  67. chatify/web/codeeditor.LICENSE.txt +21 -0
  68. chatify/web/codeeditor.js +14 -0
  69. chatify/web/components.css +151 -0
  70. chatify/web/configstate.js +30 -0
  71. chatify/web/customselect.css +26 -0
  72. chatify/web/customselect.js +12 -0
  73. chatify/web/emoji/1f389.png +0 -0
  74. chatify/web/emoji/1f389.webp +0 -0
  75. chatify/web/emoji/1f44b.png +0 -0
  76. chatify/web/emoji/1f44b.webp +0 -0
  77. chatify/web/emoji/1f44d.png +0 -0
  78. chatify/web/emoji/1f44d.webp +0 -0
  79. chatify/web/emoji/1f44f.png +0 -0
  80. chatify/web/emoji/1f44f.webp +0 -0
  81. chatify/web/emoji/1f525.png +0 -0
  82. chatify/web/emoji/1f525.webp +0 -0
  83. chatify/web/emoji/1f600.png +0 -0
  84. chatify/web/emoji/1f600.webp +0 -0
  85. chatify/web/emoji/1f602.png +0 -0
  86. chatify/web/emoji/1f602.webp +0 -0
  87. chatify/web/emoji/1f60a.png +0 -0
  88. chatify/web/emoji/1f60a.webp +0 -0
  89. chatify/web/emoji/1f60d.png +0 -0
  90. chatify/web/emoji/1f60d.webp +0 -0
  91. chatify/web/emoji/1f60e.png +0 -0
  92. chatify/web/emoji/1f60e.webp +0 -0
  93. chatify/web/emoji/1f62d.png +0 -0
  94. chatify/web/emoji/1f62d.webp +0 -0
  95. chatify/web/emoji/1f64c.png +0 -0
  96. chatify/web/emoji/1f64c.webp +0 -0
  97. chatify/web/emoji/1f680.png +0 -0
  98. chatify/web/emoji/1f680.webp +0 -0
  99. chatify/web/emoji/1f914.png +0 -0
  100. chatify/web/emoji/1f914.webp +0 -0
  101. chatify/web/emoji/1f916.png +0 -0
  102. chatify/web/emoji/1f916.webp +0 -0
  103. chatify/web/emoji/1f973.png +0 -0
  104. chatify/web/emoji/1f973.webp +0 -0
  105. chatify/web/emoji/2728.png +0 -0
  106. chatify/web/emoji/2728.webp +0 -0
  107. chatify/web/emoji/ATTRIBUTION.txt +88 -0
  108. chatify/web/emoji/STATIC-NOTO-LICENSE.txt +94 -0
  109. chatify/web/emoji/static-1f308.png +0 -0
  110. chatify/web/emoji/static-1f319.png +0 -0
  111. chatify/web/emoji/static-1f331.png +0 -0
  112. chatify/web/emoji/static-1f33b.png +0 -0
  113. chatify/web/emoji/static-1f34e.png +0 -0
  114. chatify/web/emoji/static-1f354.png +0 -0
  115. chatify/web/emoji/static-1f355.png +0 -0
  116. chatify/web/emoji/static-1f36a.png +0 -0
  117. chatify/web/emoji/static-1f375.png +0 -0
  118. chatify/web/emoji/static-1f382.png +0 -0
  119. chatify/web/emoji/static-1f389.png +0 -0
  120. chatify/web/emoji/static-1f38a.png +0 -0
  121. chatify/web/emoji/static-1f3af.png +0 -0
  122. chatify/web/emoji/static-1f3c6.png +0 -0
  123. chatify/web/emoji/static-1f431.png +0 -0
  124. chatify/web/emoji/static-1f436.png +0 -0
  125. chatify/web/emoji/static-1f438.png +0 -0
  126. chatify/web/emoji/static-1f43c.png +0 -0
  127. chatify/web/emoji/static-1f44b.png +0 -0
  128. chatify/web/emoji/static-1f44d.png +0 -0
  129. chatify/web/emoji/static-1f44e.png +0 -0
  130. chatify/web/emoji/static-1f44f.png +0 -0
  131. chatify/web/emoji/static-1f49a.png +0 -0
  132. chatify/web/emoji/static-1f49b.png +0 -0
  133. chatify/web/emoji/static-1f49c.png +0 -0
  134. chatify/web/emoji/static-1f4a1.png +0 -0
  135. chatify/web/emoji/static-1f4aa.png +0 -0
  136. chatify/web/emoji/static-1f4ac.png +0 -0
  137. chatify/web/emoji/static-1f4c4.png +0 -0
  138. chatify/web/emoji/static-1f4ce.png +0 -0
  139. chatify/web/emoji/static-1f4da.png +0 -0
  140. chatify/web/emoji/static-1f4f0.png +0 -0
  141. chatify/web/emoji/static-1f50d.png +0 -0
  142. chatify/web/emoji/static-1f50e.png +0 -0
  143. chatify/web/emoji/static-1f525.png +0 -0
  144. chatify/web/emoji/static-1f57a.png +0 -0
  145. chatify/web/emoji/static-1f600.png +0 -0
  146. chatify/web/emoji/static-1f601.png +0 -0
  147. chatify/web/emoji/static-1f602.png +0 -0
  148. chatify/web/emoji/static-1f603.png +0 -0
  149. chatify/web/emoji/static-1f604.png +0 -0
  150. chatify/web/emoji/static-1f60a.png +0 -0
  151. chatify/web/emoji/static-1f60c.png +0 -0
  152. chatify/web/emoji/static-1f60d.png +0 -0
  153. chatify/web/emoji/static-1f60e.png +0 -0
  154. chatify/web/emoji/static-1f62d.png +0 -0
  155. chatify/web/emoji/static-1f62e.png +0 -0
  156. chatify/web/emoji/static-1f634.png +0 -0
  157. chatify/web/emoji/static-1f642.png +0 -0
  158. chatify/web/emoji/static-1f64c.png +0 -0
  159. chatify/web/emoji/static-1f64f.png +0 -0
  160. chatify/web/emoji/static-1f680.png +0 -0
  161. chatify/web/emoji/static-1f913.png +0 -0
  162. chatify/web/emoji/static-1f914.png +0 -0
  163. chatify/web/emoji/static-1f916.png +0 -0
  164. chatify/web/emoji/static-1f917.png +0 -0
  165. chatify/web/emoji/static-1f91d.png +0 -0
  166. chatify/web/emoji/static-1f92f.png +0 -0
  167. chatify/web/emoji/static-1f951.png +0 -0
  168. chatify/web/emoji/static-1f970.png +0 -0
  169. chatify/web/emoji/static-1f973.png +0 -0
  170. chatify/web/emoji/static-1f979.png +0 -0
  171. chatify/web/emoji/static-1f98a.png +0 -0
  172. chatify/web/emoji/static-1f98b.png +0 -0
  173. chatify/web/emoji/static-1f9d1-200d-1f4bb.png +0 -0
  174. chatify/web/emoji/static-1f9d1-200d-1f680.png +0 -0
  175. chatify/web/emoji/static-1fae1.png +0 -0
  176. chatify/web/emoji/static-23f1-fe0f.png +0 -0
  177. chatify/web/emoji/static-2600-fe0f.png +0 -0
  178. chatify/web/emoji/static-2615.png +0 -0
  179. chatify/web/emoji/static-267e-fe0f.png +0 -0
  180. chatify/web/emoji/static-26a1.png +0 -0
  181. chatify/web/emoji/static-2705.png +0 -0
  182. chatify/web/emoji/static-2728.png +0 -0
  183. chatify/web/emoji/static-274c.png +0 -0
  184. chatify/web/emoji/static-2764-fe0f.png +0 -0
  185. chatify/web/emoji/static-2795.png +0 -0
  186. chatify/web/emoji/static-27a1-fe0f.png +0 -0
  187. chatify/web/emoji/static-2b06-fe0f.png +0 -0
  188. chatify/web/emoji/static-2b50.png +0 -0
  189. chatify/web/emoji.js +1 -0
  190. chatify/web/error.html +10 -0
  191. chatify/web/featurecatalog.js +25 -0
  192. chatify/web/feedback.css +25 -0
  193. chatify/web/feedback.js +12 -0
  194. chatify/web/icons/chatify.svg +1 -0
  195. chatify/web/index.html +60 -0
  196. chatify/web/loading.css +5 -0
  197. chatify/web/loading.js +12 -0
  198. chatify/web/logos/claude.svg +1 -0
  199. chatify/web/logos/deepseek.svg +1 -0
  200. chatify/web/logos/gemini.svg +1 -0
  201. chatify/web/logos/groq.svg +1 -0
  202. chatify/web/logos/mimo.svg +1 -0
  203. chatify/web/logos/minimax.svg +1 -0
  204. chatify/web/logos/mistral.svg +1 -0
  205. chatify/web/logos/ollama.svg +1 -0
  206. chatify/web/logos/openai.svg +1 -0
  207. chatify/web/logos/openrouter.svg +1 -0
  208. chatify/web/motion.css +472 -0
  209. chatify/web/motion.js +125 -0
  210. chatify/web/navigation.css +30 -0
  211. chatify/web/nerdload.js +25 -0
  212. chatify/web/panels.js +148 -0
  213. chatify/web/pipeline.js +164 -0
  214. chatify/web/reading.js +39 -0
  215. chatify/web/refinements.css +115 -0
  216. chatify/web/settings.css +1336 -0
  217. chatify/web/settings.html +94 -0
  218. chatify/web/settings.js +968 -0
  219. chatify/web/settingsfind.js +23 -0
  220. chatify/web/settingsguide.js +22 -0
  221. chatify/web/settingssearch.js +12 -0
  222. chatify/web/settingssimple.js +33 -0
  223. chatify/web/settingstabs.css +338 -0
  224. chatify/web/sourcekeys.js +16 -0
  225. chatify/web/splitters.js +139 -0
  226. chatify/web/style.css +935 -0
  227. chatify/web/team/dennis.jpg +0 -0
  228. chatify/web/team/harsh.jpg +0 -0
  229. chatify/web/theme.js +168 -0
  230. chatify/web/tour.css +29 -0
  231. chatify/web/tour.js +68 -0
  232. chatify/web/workspace.js +25 -0
  233. chatify/websearch.py +113 -0
  234. chatify/workspace.py +50 -0
  235. chatify_app-0.2.0.dist-info/METADATA +61 -0
  236. chatify_app-0.2.0.dist-info/RECORD +240 -0
  237. chatify_app-0.2.0.dist-info/WHEEL +5 -0
  238. chatify_app-0.2.0.dist-info/entry_points.txt +5 -0
  239. chatify_app-0.2.0.dist-info/licenses/LICENSE +21 -0
  240. chatify_app-0.2.0.dist-info/top_level.txt +1 -0
@@ -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,6 @@
1
+ """Connectors turn a source block from the app file into evidence.
2
+
3
+ Every connector exposes search(query, k) -> list of Evidence dicts:
4
+ {id, title, text, url, authors, year, venue, source, score}
5
+ """
6
+ from .base import Evidence, make_connector # noqa: F401
@@ -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