cfabric-browser 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.
Files changed (173) hide show
  1. cfabric_browser/__init__.py +3 -0
  2. cfabric_browser/__main__.py +12 -0
  3. cfabric_browser/app.py +178 -0
  4. cfabric_browser/assistant/__init__.py +8 -0
  5. cfabric_browser/assistant/guides/bhsa.md +123 -0
  6. cfabric_browser/assistant/mcp.py +225 -0
  7. cfabric_browser/assistant/proposals.py +99 -0
  8. cfabric_browser/assistant/running.py +69 -0
  9. cfabric_browser/assistant/stdio.py +276 -0
  10. cfabric_browser/assistant/tools.py +243 -0
  11. cfabric_browser/books.py +107 -0
  12. cfabric_browser/cancel.py +84 -0
  13. cfabric_browser/cli.py +400 -0
  14. cfabric_browser/compare.py +644 -0
  15. cfabric_browser/concordance.py +322 -0
  16. cfabric_browser/corpora.py +631 -0
  17. cfabric_browser/dictionaries/__init__.py +1 -0
  18. cfabric_browser/dictionaries/bdb.py +216 -0
  19. cfabric_browser/dictionaries/build.py +672 -0
  20. cfabric_browser/dictionaries/corrections.yaml +43 -0
  21. cfabric_browser/dictionaries/joins.yaml +24 -0
  22. cfabric_browser/dictionaries/link.py +254 -0
  23. cfabric_browser/dictionaries/manage.py +134 -0
  24. cfabric_browser/dictionaries/sefaria.py +240 -0
  25. cfabric_browser/dictionaries/store.py +192 -0
  26. cfabric_browser/display/__init__.py +61 -0
  27. cfabric_browser/display/config.py +500 -0
  28. cfabric_browser/display/sections.py +158 -0
  29. cfabric_browser/display/tree.py +704 -0
  30. cfabric_browser/display_settings/DT-UCPH/sp.yaml +7 -0
  31. cfabric_browser/display_settings/DT-UCPH/sp@7.2.4.yaml +16 -0
  32. cfabric_browser/downloads.py +347 -0
  33. cfabric_browser/formats.py +53 -0
  34. cfabric_browser/guard.py +86 -0
  35. cfabric_browser/helpers.py +51 -0
  36. cfabric_browser/interfaces.py +49 -0
  37. cfabric_browser/interlinear.py +185 -0
  38. cfabric_browser/jobs.py +105 -0
  39. cfabric_browser/lexicon.py +225 -0
  40. cfabric_browser/net.py +104 -0
  41. cfabric_browser/queries.py +377 -0
  42. cfabric_browser/registry.py +183 -0
  43. cfabric_browser/routes/__init__.py +59 -0
  44. cfabric_browser/routes/common.py +234 -0
  45. cfabric_browser/routes/lexicon.py +146 -0
  46. cfabric_browser/routes/library.py +367 -0
  47. cfabric_browser/routes/parallel.py +249 -0
  48. cfabric_browser/routes/proposals.py +72 -0
  49. cfabric_browser/routes/reading.py +511 -0
  50. cfabric_browser/routes/search.py +530 -0
  51. cfabric_browser/static/assets/App-Dnb20mEJ.js +1 -0
  52. cfabric_browser/static/assets/BookView-Cz8ooLry.js +1 -0
  53. cfabric_browser/static/assets/ConcordanceApp-DAJGIi6L.js +1 -0
  54. cfabric_browser/static/assets/InterfaceMenu-C0115Vat.js +9 -0
  55. cfabric_browser/static/assets/InterfaceMenu-Dl65sCtJ.css +1 -0
  56. cfabric_browser/static/assets/InterlinearApp-B-znfkr9.js +1 -0
  57. cfabric_browser/static/assets/LexiconView-BVsZI_sa.js +1 -0
  58. cfabric_browser/static/assets/Library-BMUS5-nO.js +2 -0
  59. cfabric_browser/static/assets/Mark-CJT08VRv.js +1 -0
  60. cfabric_browser/static/assets/Navigate-BXzUyAFT.js +1 -0
  61. cfabric_browser/static/assets/ParallelApp-BSNAzyxT.js +2 -0
  62. cfabric_browser/static/assets/QueryEditor-DzhpEhTk.js +14 -0
  63. cfabric_browser/static/assets/ReaderApp-CcJS5D-d.js +1 -0
  64. cfabric_browser/static/assets/Search-BQulV0gk.js +1 -0
  65. cfabric_browser/static/assets/SearchView-CMq5k7Qs.js +6 -0
  66. cfabric_browser/static/assets/Texts-DSY6lKNw.js +1 -0
  67. cfabric_browser/static/assets/amiri-arabic-400-normal-C_1DQziU.woff +0 -0
  68. cfabric_browser/static/assets/amiri-arabic-400-normal-D0NIBXga.woff2 +0 -0
  69. cfabric_browser/static/assets/amiri-latin-400-normal-Bv6IM0kz.woff +0 -0
  70. cfabric_browser/static/assets/amiri-latin-400-normal-Czk41Rjn.woff2 +0 -0
  71. cfabric_browser/static/assets/amiri-latin-ext-400-normal-C9hoIjPx.woff2 +0 -0
  72. cfabric_browser/static/assets/amiri-latin-ext-400-normal-CGn-6a7O.woff +0 -0
  73. cfabric_browser/static/assets/gentium-plus-cyrillic-400-normal-2gl_A-Tn.woff +0 -0
  74. cfabric_browser/static/assets/gentium-plus-cyrillic-400-normal-BKY-Jqah.woff2 +0 -0
  75. cfabric_browser/static/assets/gentium-plus-cyrillic-ext-400-normal-B9Nl3F-2.woff +0 -0
  76. cfabric_browser/static/assets/gentium-plus-cyrillic-ext-400-normal-CJ4YKexy.woff2 +0 -0
  77. cfabric_browser/static/assets/gentium-plus-greek-400-normal-CYsP5XPD.woff +0 -0
  78. cfabric_browser/static/assets/gentium-plus-greek-400-normal-FlMNHri1.woff2 +0 -0
  79. cfabric_browser/static/assets/gentium-plus-greek-ext-400-normal-DOc93t8p.woff2 +0 -0
  80. cfabric_browser/static/assets/gentium-plus-greek-ext-400-normal-Vs3SB95H.woff +0 -0
  81. cfabric_browser/static/assets/gentium-plus-latin-400-normal-Bx4sytbw.woff2 +0 -0
  82. cfabric_browser/static/assets/gentium-plus-latin-400-normal-CBUjaU8k.woff +0 -0
  83. cfabric_browser/static/assets/gentium-plus-latin-ext-400-normal-B46t2ITS.woff +0 -0
  84. cfabric_browser/static/assets/gentium-plus-latin-ext-400-normal-BID1L8QP.woff2 +0 -0
  85. cfabric_browser/static/assets/gentium-plus-vietnamese-400-normal-C_3yQKPY.woff +0 -0
  86. cfabric_browser/static/assets/gentium-plus-vietnamese-400-normal-lfG8qd7w.woff2 +0 -0
  87. cfabric_browser/static/assets/index-CerWo5pG.css +1 -0
  88. cfabric_browser/static/assets/index-DcB4YcRU.js +50 -0
  89. cfabric_browser/static/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  90. cfabric_browser/static/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  91. cfabric_browser/static/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  92. cfabric_browser/static/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  93. cfabric_browser/static/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  94. cfabric_browser/static/assets/noto-sans-syriac-latin-400-normal-D2_119oK.woff2 +0 -0
  95. cfabric_browser/static/assets/noto-sans-syriac-latin-400-normal-DpW_KDWJ.woff +0 -0
  96. cfabric_browser/static/assets/noto-sans-syriac-latin-ext-400-normal-CKWjGUw2.woff2 +0 -0
  97. cfabric_browser/static/assets/noto-sans-syriac-latin-ext-400-normal-G7wAZA1M.woff +0 -0
  98. cfabric_browser/static/assets/noto-sans-syriac-syriac-400-normal-Bz33PzP0.woff +0 -0
  99. cfabric_browser/static/assets/noto-sans-syriac-syriac-400-normal-CWH9OPix.woff2 +0 -0
  100. cfabric_browser/static/assets/noto-serif-hebrew-hebrew-400-normal-jyUp5Tfv.woff +0 -0
  101. cfabric_browser/static/assets/noto-serif-hebrew-hebrew-400-normal-v_xU1ROG.woff2 +0 -0
  102. cfabric_browser/static/assets/noto-serif-hebrew-hebrew-600-normal-5Q1AJcd_.woff2 +0 -0
  103. cfabric_browser/static/assets/noto-serif-hebrew-hebrew-600-normal-BBqYD5TV.woff +0 -0
  104. cfabric_browser/static/assets/noto-serif-hebrew-latin-400-normal-BWkL_LQQ.woff +0 -0
  105. cfabric_browser/static/assets/noto-serif-hebrew-latin-400-normal-D22OmSTw.woff2 +0 -0
  106. cfabric_browser/static/assets/noto-serif-hebrew-latin-600-normal-DUOQhjy6.woff2 +0 -0
  107. cfabric_browser/static/assets/noto-serif-hebrew-latin-600-normal-fgAR10AF.woff +0 -0
  108. cfabric_browser/static/assets/noto-serif-hebrew-latin-ext-400-normal-BiXgBxzW.woff2 +0 -0
  109. cfabric_browser/static/assets/noto-serif-hebrew-latin-ext-400-normal-DPDY_J25.woff +0 -0
  110. cfabric_browser/static/assets/noto-serif-hebrew-latin-ext-600-normal-D4x26vgI.woff +0 -0
  111. cfabric_browser/static/assets/noto-serif-hebrew-latin-ext-600-normal-DSAqAqQ_.woff2 +0 -0
  112. cfabric_browser/static/assets/source-serif-4-cyrillic-ext-wght-italic-y6hTrs-Y.woff2 +0 -0
  113. cfabric_browser/static/assets/source-serif-4-cyrillic-ext-wght-normal-DHTKMUil.woff2 +0 -0
  114. cfabric_browser/static/assets/source-serif-4-cyrillic-wght-italic-CRzqOTyj.woff2 +0 -0
  115. cfabric_browser/static/assets/source-serif-4-cyrillic-wght-normal-B1g8a0vD.woff2 +0 -0
  116. cfabric_browser/static/assets/source-serif-4-greek-wght-italic-CUp-f2uw.woff2 +0 -0
  117. cfabric_browser/static/assets/source-serif-4-greek-wght-normal-BpXpdlRW.woff2 +0 -0
  118. cfabric_browser/static/assets/source-serif-4-latin-ext-wght-italic-B_cmBfNX.woff2 +0 -0
  119. cfabric_browser/static/assets/source-serif-4-latin-ext-wght-normal-BVZ5su8S.woff2 +0 -0
  120. cfabric_browser/static/assets/source-serif-4-latin-wght-italic-B-rCj6ir.woff2 +0 -0
  121. cfabric_browser/static/assets/source-serif-4-latin-wght-normal-DMe4EKeb.woff2 +0 -0
  122. cfabric_browser/static/assets/source-serif-4-vietnamese-wght-italic-D3EGHrWW.woff2 +0 -0
  123. cfabric_browser/static/assets/source-serif-4-vietnamese-wght-normal-BsaNuasd.woff2 +0 -0
  124. cfabric_browser/static/assets/terms-DEvE2HBo.js +2 -0
  125. cfabric_browser/static/favicon.svg +4 -0
  126. cfabric_browser/static/guide/adding-texts.html +67 -0
  127. cfabric_browser/static/guide/asking-claude.html +127 -0
  128. cfabric_browser/static/guide/concordance.html +75 -0
  129. cfabric_browser/static/guide/datasets.html +142 -0
  130. cfabric_browser/static/guide/getting-started.html +146 -0
  131. cfabric_browser/static/guide/images/bdb.png +0 -0
  132. cfabric_browser/static/guide/images/collocations.png +0 -0
  133. cfabric_browser/static/guide/images/concordance.png +0 -0
  134. cfabric_browser/static/guide/images/differences.png +0 -0
  135. cfabric_browser/static/guide/images/hover.png +0 -0
  136. cfabric_browser/static/guide/images/inspector.png +0 -0
  137. cfabric_browser/static/guide/images/interlinear.png +0 -0
  138. cfabric_browser/static/guide/images/lexicon.png +0 -0
  139. cfabric_browser/static/guide/images/library.png +0 -0
  140. cfabric_browser/static/guide/images/menu.png +0 -0
  141. cfabric_browser/static/guide/images/navigate.png +0 -0
  142. cfabric_browser/static/guide/images/panes.png +0 -0
  143. cfabric_browser/static/guide/images/parallel.png +0 -0
  144. cfabric_browser/static/guide/images/proposal.png +0 -0
  145. cfabric_browser/static/guide/images/reader-dark.png +0 -0
  146. cfabric_browser/static/guide/images/reader.png +0 -0
  147. cfabric_browser/static/guide/images/search-terms.png +0 -0
  148. cfabric_browser/static/guide/images/search.png +0 -0
  149. cfabric_browser/static/guide/images/statistics.png +0 -0
  150. cfabric_browser/static/guide/images/workspaces.png +0 -0
  151. cfabric_browser/static/guide/index.html +62 -0
  152. cfabric_browser/static/guide/interfaces.html +115 -0
  153. cfabric_browser/static/guide/notes-and-citing.html +74 -0
  154. cfabric_browser/static/guide/problems.html +71 -0
  155. cfabric_browser/static/guide/reading.html +105 -0
  156. cfabric_browser/static/guide/searching.html +223 -0
  157. cfabric_browser/static/guide/words.html +107 -0
  158. cfabric_browser/static/index.html +14 -0
  159. cfabric_browser/summary.py +202 -0
  160. cfabric_browser/translations/__init__.py +12 -0
  161. cfabric_browser/translations/build.py +505 -0
  162. cfabric_browser/translations/catalog.py +164 -0
  163. cfabric_browser/translations/hebrew_verses.json +41 -0
  164. cfabric_browser/translations/manage.py +123 -0
  165. cfabric_browser/window.py +81 -0
  166. cfabric_browser/witnesses.py +254 -0
  167. cfabric_browser/words.py +68 -0
  168. cfabric_browser/workers.py +627 -0
  169. cfabric_browser-0.1.0.dist-info/METADATA +399 -0
  170. cfabric_browser-0.1.0.dist-info/RECORD +173 -0
  171. cfabric_browser-0.1.0.dist-info/WHEEL +5 -0
  172. cfabric_browser-0.1.0.dist-info/entry_points.txt +3 -0
  173. cfabric_browser-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """CACCHT Explorer, the Context-Fabric browser."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,12 @@
1
+ """`python -m cfabric_browser`, and the entry point of the standalone program."""
2
+
3
+ import multiprocessing
4
+ import sys
5
+
6
+ from cfabric_browser.cli import main
7
+
8
+ if __name__ == "__main__":
9
+ # In a frozen program (PyInstaller), each search worker starts the program
10
+ # again; this turns that start into the worker instead of a second server.
11
+ multiprocessing.freeze_support()
12
+ sys.exit(main())
cfabric_browser/app.py ADDED
@@ -0,0 +1,178 @@
1
+ """The browser's web application: the JSON API plus the single-page interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator
6
+ from contextlib import asynccontextmanager
7
+ from importlib import metadata
8
+ from pathlib import Path
9
+
10
+ from fastapi import FastAPI, HTTPException
11
+ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
12
+ from fastapi.staticfiles import StaticFiles
13
+ from mcp.server.fastmcp.server import StreamableHTTPASGIApp
14
+ from starlette.routing import Route
15
+
16
+ from cfabric_browser import __version__
17
+ from cfabric_browser.assistant.mcp import build_server as build_mcp
18
+ from cfabric_browser.assistant.mcp import local
19
+ from cfabric_browser.assistant.tools import Tools
20
+ from cfabric_browser.corpora import CorpusService
21
+ from cfabric_browser.dictionaries.manage import DictionaryJobs
22
+ from cfabric_browser.downloads import DownloadJobs
23
+ from cfabric_browser.guard import Guard
24
+ from cfabric_browser.routes import build_router
25
+ from cfabric_browser.translations.manage import Jobs
26
+ from cfabric_browser.workers import SearchPool
27
+
28
+ STATIC = Path(__file__).parent / "static"
29
+
30
+ NOT_BUILT = """<!doctype html>
31
+ <html lang="en"><head><meta charset="utf-8"><title>CACCHT Explorer</title></head>
32
+ <body style="font-family: system-ui; max-width: 40rem; margin: 4rem auto">
33
+ <h1>CACCHT Explorer</h1>
34
+ <p>The server is running, but the interface has not been built.</p>
35
+ <p>Build it with <code>npm install</code> and <code>npm run build</code> in
36
+ <code>libs/explorer/frontend</code>, or use the API at <a href="/docs">/docs</a>.</p>
37
+ </body></html>
38
+ """
39
+
40
+
41
+ def create_app(
42
+ service: CorpusService,
43
+ pool: SearchPool,
44
+ static_dir: Path = STATIC,
45
+ manage_pool: bool = True,
46
+ library: bool = True,
47
+ ui: str = "classic",
48
+ hidden_uis: list[str] | None = None,
49
+ translations: Jobs | None = None,
50
+ downloads: DownloadJobs | None = None,
51
+ ask_downloads: bool = False,
52
+ dictionaries: DictionaryJobs | None = None,
53
+ assistant: Tools | None = None,
54
+ hosts: list[str] | None = None,
55
+ ) -> FastAPI:
56
+ """Build the application.
57
+
58
+ Parameters
59
+ ----------
60
+ service: CorpusService
61
+ The corpora to serve.
62
+ pool: SearchPool
63
+ The search workers.
64
+ static_dir: Path
65
+ Directory with the built interface (`index.html` and `assets/`).
66
+ manage_pool: bool
67
+ Start the pool when the app starts and stop it when it stops.
68
+ library: bool
69
+ Let people open other corpora found on this computer. Switch this off
70
+ when the browser is reachable by others.
71
+ ui: str
72
+ The interface to start in when the user has not chosen one yet:
73
+ `classic` (panes), `parallel`, `reader`, `interlinear` or
74
+ `concordance` (see `cfabric_browser.interfaces`).
75
+ hidden_uis: list[str] | None
76
+ Interfaces to leave out of the menu until the user chooses otherwise.
77
+ translations: Jobs | None
78
+ Where translations are built and how (`cfabric_browser.translations`);
79
+ by default into the first place the library looks.
80
+ downloads: DownloadJobs | None
81
+ Where corpora are downloaded to (`cfabric_browser.downloads`); by
82
+ default the first place the library looks.
83
+ ask_downloads: bool
84
+ At the start, offer to download the corpora that can be downloaded and
85
+ are missing (the standalone program).
86
+ dictionaries: DictionaryJobs | None
87
+ Where dictionaries (BDB) are kept (`cfabric_browser.dictionaries`); by
88
+ default `~/.cfabric/dictionaries`.
89
+ assistant: Tools | None
90
+ The tools of searching in plain language and their proposals
91
+ (`cfabric_browser.assistant`); by default on these corpora and pool.
92
+ hosts: list[str] | None
93
+ The host names the server answers to (`cfabric_browser.guard`), such
94
+ as `guard.LOCAL_HOSTS` when it listens only on this computer; `None`
95
+ for any. Requests from other web sites are refused either way.
96
+ """
97
+
98
+ # searching in plain language: the tools, also as an MCP server at /mcp
99
+ tools = assistant if assistant is not None else Tools(service, pool)
100
+ mcpServer = build_mcp(local(tools))
101
+ mcpServer.streamable_http_app() # makes its session manager
102
+
103
+ @asynccontextmanager
104
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
105
+ if manage_pool:
106
+ pool.start(wait=False)
107
+ try:
108
+ async with mcpServer.session_manager.run():
109
+ yield
110
+ finally:
111
+ if manage_pool:
112
+ pool.shutdown()
113
+
114
+ app = FastAPI(
115
+ title="CACCHT Explorer",
116
+ version=__version__,
117
+ lifespan=lifespan,
118
+ )
119
+ app.add_middleware(Guard, hosts=hosts)
120
+ app.include_router(
121
+ build_router(
122
+ service,
123
+ pool,
124
+ library=library,
125
+ translations=translations,
126
+ downloads=downloads,
127
+ dictionaries=dictionaries,
128
+ assistant=tools,
129
+ )
130
+ )
131
+ # before the interface's catch-all route
132
+ app.router.routes.insert(
133
+ 0, Route("/mcp", endpoint=StreamableHTTPASGIApp(mcpServer.session_manager))
134
+ )
135
+
136
+ try:
137
+ coreVersion = metadata.version("context-fabric")
138
+ except metadata.PackageNotFoundError:
139
+ coreVersion = None
140
+
141
+ @app.get("/api/health")
142
+ def health() -> dict:
143
+ return {
144
+ "ok": True,
145
+ "searchReady": pool.ready,
146
+ "version": __version__,
147
+ "coreVersion": coreVersion,
148
+ "ui": ui,
149
+ "hiddenUis": list(hidden_uis or []),
150
+ # offer the missing corpora at the start (/api/downloads)
151
+ "askDownloads": ask_downloads and library,
152
+ }
153
+
154
+ index = static_dir / "index.html"
155
+ assets = static_dir / "assets"
156
+ if assets.is_dir():
157
+ app.mount("/assets", StaticFiles(directory=assets), name="assets")
158
+
159
+ @app.get("/{path:path}", include_in_schema=False)
160
+ def spa(path: str):
161
+ # the interface does its own routing: every non-API path gets the page
162
+ if path.startswith("api/"):
163
+ raise HTTPException(404, "Not found")
164
+ candidate = (static_dir / path).resolve()
165
+ inside = bool(path) and candidate.is_relative_to(static_dir.resolve())
166
+ if inside and candidate.is_file():
167
+ return FileResponse(candidate)
168
+ # a folder of pages (the guide, /guide/): its index.html, at an address
169
+ # ending in / so that the pages' relative links work
170
+ if inside and (candidate / "index.html").is_file():
171
+ if not path.endswith("/"):
172
+ return RedirectResponse(f"/{path}/")
173
+ return FileResponse(candidate / "index.html")
174
+ if index.is_file():
175
+ return FileResponse(index)
176
+ return HTMLResponse(NOT_BUILT)
177
+
178
+ return app
@@ -0,0 +1,8 @@
1
+ """Searching in plain language: the tools a language model gets to turn a
2
+ request ("all cases of >LHJM/ in the BHSA") into a search template that the
3
+ browser shows and runs.
4
+
5
+ `tools.py` has the tools; the model reaches them through MCP (the user's own
6
+ AI app, with their own subscription). The results always come from the
7
+ browser's search, never from the model's text.
8
+ """
@@ -0,0 +1,123 @@
1
+ # Searching the BHSA
2
+
3
+ The BHSA (Biblia Hebraica Stuttgartensia Amstelodamensis, ETCBC, version 2021) is the Hebrew Bible with linguistic annotations on words, phrases, clauses and sentences. A search template describes objects, one per line; indentation means "inside". The browser runs it; you only write it.
4
+
5
+ ## Object types (how many), from large to small
6
+
7
+ book (39) > chapter (929) > verse (23,213) > half_verse (45,179) > sentence (63,717) > clause (88,131) > phrase (253,203) > word (426,590).
8
+
9
+ Also: `sentence_atom`, `clause_atom`, `phrase_atom`, `subphrase` (finer divisions), and `lex` (9,230 lexemes). A `lex` node contains all the words of that lexeme, so `lex gloss=create` with `word` indented under it finds the words of lexemes glossed "create".
10
+
11
+ ## The main features
12
+
13
+ **Words** (`word`):
14
+
15
+ - `lex`: the lexeme in ETCBC transliteration (see *Conventions*): `>LHJM/`, `HLK[`, `JHWH/`.
16
+ - `sp`: part of speech: `subs` noun, `verb`, `prep` preposition, `conj` conjunction, `nmpr` proper noun, `art` article, `adjv` adjective, `nega` negation, `prps` personal pronoun, `advb` adverb, `prde` demonstrative pronoun, `intj` interjection, `inrg` interrogative particle, `prin` interrogative pronoun.
17
+ - `vs`: verbal stem: `qal`, `nif` niphal, `piel`, `pual`, `hit` hitpael, `hif` hiphil, `hof` hophal, and rarer ones (`hsht`, `poel`, ...); Aramaic `peal`, `pael`, `haf`, `afel`, `peil`, `htpe`, `htpa`, ... `NA` for words that aren't verbs.
18
+ - `vt`: verbal tense: `perf` perfect (qatal), `impf` imperfect (yiqtol), `wayq` wayyiqtol, `impv` imperative, `infc` infinitive construct, `infa` infinitive absolute, `ptca` active participle, `ptcp` passive participle.
19
+ - `ps` person (`p1`, `p2`, `p3`), `gn` gender (`m`, `f`), `nu` number (`sg`, `pl`, `du`); `unknown` where it can't be told (common gender, for instance), `NA` where it doesn't apply.
20
+ - `st`: state: `a` absolute, `c` construct, `e` emphatic (Aramaic).
21
+ - Pronominal suffix: `prs` its consonants (`W`, `K`, `HM`, ...; `absent` without one), with `prs_ps`, `prs_gn`, `prs_nu`.
22
+ - `language`: `Hebrew` or `Aramaic` (6,106 Aramaic words, in Daniel, Ezra and a few verses elsewhere).
23
+ - `nametype` of proper nouns: `pers` person, `topo` place, `gens` people, `ppde`, `mens`, and combinations such as `pers,gens,topo`: use `nametype~topo` to include those.
24
+ - `ls`: lexical set: `card` cardinal, `ordn` ordinal, `gntl` gentilic, `quot` quotation verb (אמר), `ques`, `nmdi` distributive noun, ...
25
+ - Text: `g_cons` (consonants in transliteration, without vowels: `>LHJM`), `g_word_utf8` (the word in Hebrew with vowels), `g_lex_utf8`.
26
+ - `gloss` (English) is on `lex` nodes, not on words.
27
+
28
+ **Phrases** (`phrase`):
29
+
30
+ - `function`: what the phrase does in its clause: `Pred` predicate, `Subj` subject, `Objc` object, `Cmpl` complement, `Adju` adjunct, `Time`, `Loca` location, `Modi` modifier, `Conj` conjunction, `Nega` negation, `Ques` question, `Rela` relative, `Voct` vocative, `Intj`, `PreC` predicate complement, `PreO` predicate with object suffix, `PreS` predicate with subject suffix, `Frnt` fronted element, `Supp` supplementary constituent, ...
31
+ - `typ`: phrase type: `VP` verbal, `NP` nominal, `PP` prepositional, `CP` conjunctive, `PrNP` proper-noun phrase, `AdvP`, `NegP`, `InrP`, `AdjP`, `PPrP` personal pronoun, `DPrP` demonstrative pronoun, ...
32
+ - `det`: determination: `det`, `und` (undetermined).
33
+
34
+ **Clauses** (`clause`):
35
+
36
+ - `typ`: clause type, by the form of its verb and what comes before it: `Way0` wayyiqtol first, `WayX` wayyiqtol after something, `WQt0` weqatal first, `xQt0` qatal after a word, `xQtX` qatal after a constituent, `XQtl` qatal after a non-subject, `ZQt0`, `ZYq0` (no conjunction), `xYq0`, `WYq0` (yiqtol), `ZIm0` imperative, `NmCl` nominal clause, `AjCl` adjectival, `InfC` infinitive construct clause, `Ptcp` participle clause, `Ellp` ellipsis, `Voct`, `Defc` defective, `CPen` casus pendens, `MSyn` macrosyntactic sign, ...
37
+ - `rela`: relation to another clause: `Objc` object clause, `Subj` subject clause, `Attr` attributive (relative), `Adju` adjunct, `Cmpl`, `PreC`, `RgRc` regens/rectum, `Resu` resumptive, `Coor` coordinated, `Spec`, `Appo`, `Link`, ...; `NA` for main clauses.
38
+ - `kind`: `VC` verbal clause, `NC` nominal clause, `WP` without predicate.
39
+ - `domain`: `N` narrative, `Q` quotation (direct speech), `D` discursive, `?` unknown.
40
+ - `txt`: text type by levels of embedding (`N`, `Q`, `NQ`, `NQQ`, ...).
41
+
42
+ **Sections**: `book book=Genesis`, `chapter chapter=1`, `verse verse=3`, indented in that order.
43
+
44
+ ## Conventions
45
+
46
+ - **Lexemes** are written in ETCBC transliteration: `>` א, `B` ב, `G` ג, `D` ד, `H` ה, `W` ו, `Z` ז, `X` ח, `V` ט, `J` י, `K` כ, `L` ל, `M` מ, `N` נ, `S` ס, `<` ע, `P` פ, `Y` צ, `Q` ק, `R` ר, `F` שׂ, `C` שׁ, `T` ת (final forms the same).
47
+ - A verb's lexeme ends in `[` (`HLK[` to go, `BR>[` to create), a noun's or adjective's in `/` (`>LHJM/` god(s), `MLK/` king, `VWB/` good), particles have neither (`L` to, `>CR` which, `KJ` that, `W` and, `H` the article, `H=` the interrogative he).
48
+ - `=` marks homonyms: `DBR[` speak and `DBR/` word; `>MR[` say; `H` the article but `H=` the interrogative; `QW=/`, `<RB===/`. When a lexeme seems to give too few results, check its homonyms with `describe_feature` on `lex`, or look for its gloss: `lex gloss=word`.
49
+ - **Book names** in the `book` feature are Latin, with Roman numerals after the name: Genesis, Exodus, Leviticus, Numeri, Deuteronomium, Josua, Judices, Samuel_I, Samuel_II, Reges_I, Reges_II, Jesaia, Jeremia, Ezechiel, Hosea, Joel, Amos, Obadia, Jona, Micha, Nahum, Habakuk, Zephania, Haggai, Sacharia, Maleachi, Psalmi, Iob, Proverbia, Ruth, Canticum, Ecclesiastes, Threni, Esther, Daniel, Esra, Nehemia, Chronica_I, Chronica_II. So Psalms is `book book=Psalmi`, 1 Kings is `book book=Reges_I`.
50
+ - Values are exact: `vt=infc`, not `vt=inf` or `vt=infinitive`. Several values with `|`: `prs_ps=p1|p2`. Not a value with `#`: `prs#absent` (with a suffix). A pattern with `~`: `nametype~topo`, `gloss~^king` (starting with "king").
51
+ - Hebrew and Aramaic words have the same features; add `language=Aramaic` (or `Hebrew`) to keep to one.
52
+
53
+ ## Templates in short
54
+
55
+ - One object per line; an indented line lies inside the object above it.
56
+ - Features after the type: `word sp=verb vs=hif`.
57
+ - Two objects at the same level in no particular order, unless related: name them and add a relation on a line of its own: `p1:phrase function=Pred`, `p2:phrase function=Subj`, then `p1 < p2` (p1 before p2). `<:` means "right before" (adjacent).
58
+ - Quantifiers, each closed with `/-/`: `/with/` (has this inside), `/without/` (none of these inside), `/where/ … /have/` (every object of the `/where/` part also fits the `/have/` part). In `/have/`, name the object of `/where/` and refer to it: `w:word` under `/where/`, `w sp=nmpr` under `/have/`. A new line such as `word sp=nmpr` under `/have/` is another object, so it only asks that there is one. `/where/` also lets through what has no such object at all; "only proper names" is shorter as `/without/` with `word sp#nmpr`.
59
+ - Results are tuples, one node per line of the template; the count is the number of tuples. Ask for the object the user wants counted at the top, or as the only line.
60
+
61
+ ## Worked examples (with their number of results in the BHSA)
62
+
63
+ | Request | Template | Results |
64
+ |---|---|---|
65
+ | all cases of >LHJM/ | `word lex=>LHJM/` | 2,601 |
66
+ | the verb HLK[ in all its forms | `word lex=HLK[` | 1,554 |
67
+ | HLK[ in the qal | `word lex=HLK[ vs=qal` | 1,412 |
68
+ | infinitives construct of HLK[ | `word lex=HLK[ vt=infc` | 144 |
69
+ | active participles of >MR[ | `word lex=>MR[ vt=ptca` | 138 |
70
+ | the niphal of DBR[ | `word lex=DBR[ vs=nif` | 4 |
71
+ | hiphil imperfects | `word vs=hif vt=impf` | 2,264 |
72
+ | perfects, second person feminine singular | `word vt=perf ps=p2 gn=f nu=sg` | 222 |
73
+ | the name YHWH | `word lex=JHWH/` | 6,828 |
74
+ | Aramaic words | `word language=Aramaic` | 6,106 |
75
+ | place names | `word sp=nmpr nametype=topo` | 6,371 |
76
+ | place names, also those that are also personal names | `word sp=nmpr nametype~topo` | 10,916 |
77
+ | feminine plural nouns | `word sp=subs gn=f nu=pl` | 5,992 |
78
+ | nouns in the construct state | `word sp=subs st=c` | 36,679 |
79
+ | verbs with a suffix of the first person | `word sp=verb prs_ps=p1` | 1,649 |
80
+ | the interrogative he | `word lex=H=` | 743 |
81
+ | words with the consonants >LHJM, of any lexeme | `word g_cons=>LHJM` | 1,177 |
82
+ | the words of lexemes glossed "create" | `lex gloss=create` / ` word` | 54 |
83
+ | the words of lexemes whose gloss starts with "king" | `lex gloss~^king` / ` word` | 3,001 |
84
+ | wayyiqtol forms in Genesis | `book book=Genesis` / ` word vt=wayq` | 2,106 |
85
+ | imperatives in the Psalms | `book book=Psalmi` / ` word vt=impv` | 701 |
86
+ | >LHJM/ in Genesis 1 | `book book=Genesis` / ` chapter chapter=1` / ` word lex=>LHJM/` | 32 |
87
+ | phrases that are the subject | `phrase function=Subj` | 31,979 |
88
+ | determined noun phrases | `phrase typ=NP det=det` | 20,476 |
89
+ | clauses with a predicate and a subject | `clause` / ` phrase function=Pred` / ` phrase function=Subj` | 18,966 |
90
+ | clauses with the predicate before the subject | `clause` / ` p1:phrase function=Pred` / ` p2:phrase function=Subj` / `p1 < p2` | 13,938 |
91
+ | clauses with the subject before the predicate | `clause` / ` p1:phrase function=Subj` / ` p2:phrase function=Pred` / `p1 < p2` | 5,028 |
92
+ | clauses whose subject is >LHJM/, with a verb as predicate | `clause` / ` phrase function=Pred` / ` word sp=verb` / ` phrase function=Subj` / ` word lex=>LHJM/` | 614 |
93
+ | clauses where >LHJM/ is the subject of >MR[ | `clause` / ` phrase function=Pred` / ` word lex=>MR[` / ` phrase function=Subj` / ` word lex=>LHJM/` | 147 |
94
+ | clauses with HJH[ as predicate | `clause` / ` phrase function=Pred` / ` word lex=HJH[` | 3,521 |
95
+ | nominal clauses | `clause typ=NmCl` | 11,377 |
96
+ | wayyiqtol clauses with nothing before the verb | `clause typ=Way0` | 9,038 |
97
+ | qatal after a constituent (x-qatal-X) | `clause typ=xQtX` | 2,797 |
98
+ | object clauses | `clause rela=Objc` | 1,327 |
99
+ | relative clauses with >CR | `clause` / ` phrase function=Rela` / ` word lex=>CR` | 4,738 |
100
+ | negated clauses | `clause` / ` phrase function=Nega` | 6,044 |
101
+ | clauses in direct speech | `clause domain=Q` | 52,937 |
102
+ | an infinitive construct right after the preposition L | `phrase` / ` w1:word lex=L` / ` w2:word vt=infc` / `w1 <: w2` | 4,572 |
103
+ | a noun followed right away by an adjective | `phrase typ=NP` / ` w1:word sp=subs` / ` w2:word sp=adjv` / `w1 <: w2` | 1,579 |
104
+ | >MR[ followed in its clause by L with a suffix | `clause` / ` w1:word lex=>MR[` / ` w2:word lex=L prs#absent` / `w1 < w2` | 347 |
105
+ | verses with both >LHJM/ and BR>[ (21 combinations of a verse and the two words, in 12 verses) | `verse` / ` word lex=>LHJM/` / ` word lex=BR>[` | 21 |
106
+ | verses without a verb | `verse` / `/without/` / ` word sp=verb` / `/-/` | 1,607 |
107
+ | subjects that are only proper names | `phrase function=Subj` / `/where/` / ` w:word` / `/have/` / ` w sp=nmpr` / `/-/` | 5,893 |
108
+ | the same, shorter | `phrase function=Subj` / `/without/` / ` word sp#nmpr` / `/-/` | 5,893 |
109
+ | subjects with at least one proper name | `phrase function=Subj` / `/with/` / ` word sp=nmpr` / `/-/` | 10,484 |
110
+ | verses with both >HB[ and JHWH/, each verse once | `verse` / `/with/` / ` word lex=>HB[` / `/-/` / `/with/` / ` word lex=JHWH/` / `/-/` | 60 |
111
+
112
+ (`/` separates the lines of a template here; write each on its own line, with the indentation shown.)
113
+
114
+ ## Common mistakes
115
+
116
+ - English or Hebrew values: `sp=noun`, `vt=infinitive`, `vs=hiphil` find nothing; use the codes above (`subs`, `infc`, `hif`).
117
+ - A lexeme without its ending: `lex=HLK` finds nothing; it is `HLK[`. `try_search` names a value that a feature never has.
118
+ - English book names: `book=Psalms` finds nothing; it is `Psalmi`.
119
+ - `gloss` on a word: it is on `lex` nodes (`lex gloss=create`, with `word` under it).
120
+ - `function` on a word or a clause: it is a feature of phrases.
121
+ - The article `H` for the interrogative `H=`, `DBR[` (speak) for `DBR/` (word).
122
+ - `/where/` with a new object under `/have/` (`/where/` / ` word` / `/have/` / ` word sp=nmpr`): that finds subjects with at least one proper name (10,484), not subjects of proper names only (5,893). Name the object and refer to it, or use `/without/`.
123
+ - Counting: `verse` with two words under it gives a result for every pair of matching words in the verse, not one per verse. To count verses, keep the verse alone with its conditions inside a quantifier, or tell the user that the count is of combinations.
@@ -0,0 +1,225 @@
1
+ """The tools as an MCP server, for the user's own AI app (Claude Code,
2
+ Claude Desktop, the ChatGPT desktop app, Codex, ...).
3
+
4
+ They are served at `/mcp` on the browser (Streamable HTTP, stateless, with
5
+ JSON answers), for apps that reach a local HTTP server (Claude Code, Codex). It
6
+ accepts only 127.0.0.1/localhost as host and no web page as origin, so
7
+ another tab in a web browser can't use it. Apps that start their own server
8
+ over stdio (Claude Desktop, the ChatGPT desktop app) go through a small proxy that passes each message
9
+ on to `/mcp`: `cfabric-browser mcp` (`stdio.py`), or the Desktop Extension's
10
+ own (`packaging/mcpb/`).
11
+
12
+ All tools but `propose_search` are marked read-only, so that apps which ask
13
+ before each tool that changes something (Codex) ask only for the proposal.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Awaitable, Callable
19
+ from typing import Any
20
+
21
+ from anyio import to_thread
22
+ from mcp.server.fastmcp import Context, FastMCP
23
+ from mcp.server.transport_security import TransportSecuritySettings
24
+ from mcp.types import ToolAnnotations
25
+
26
+ # calls a tool by name with its arguments; returns what the tool returns
27
+ Call = Callable[[str, dict[str, Any]], Awaitable[Any]]
28
+
29
+ INSTRUCTIONS = """\
30
+ You are helping a user search corpora (the Hebrew Bible as the BHSA, and
31
+ others) that are open in CACCHT Explorer. The user says in their
32
+ own words what they want to find; you write a search template for it.
33
+
34
+ The results always come from the browser's search, never from your own
35
+ knowledge: don't answer with counts or lists of passages yourself.
36
+
37
+ 1. list_corpora, then corpus_guide for the corpus (it explains the features
38
+ and conventions, with examples); describe_corpus, list_features and
39
+ describe_feature for more.
40
+ 2. Write a template (search_syntax_guide explains the language) and check it
41
+ with check_template.
42
+ 3. Run it as a trial with try_search: look at the count and the examples. If
43
+ there are 0 results, read the hints; if far too many, narrow it. Repeat.
44
+ 4. propose_search with the template, the user's request in their own words
45
+ and a short explanation of each line. The browser shows it to the user,
46
+ who runs it with Run. Tell the user it is there, with the trial's count.
47
+ """
48
+
49
+ # the header in which `cfabric-browser mcp` passes on the app's name, from
50
+ # its `initialize` (`/mcp` is stateless, so it never sees that itself)
51
+ CLIENT_HEADER = "X-Cfabric-Client"
52
+
53
+ # the names apps give themselves (`clientInfo.name`, or in their User-Agent),
54
+ # as the user knows them; the first that occurs in it
55
+ CLIENTS = (
56
+ ("chatgpt", "ChatGPT"),
57
+ ("codex", "Codex"),
58
+ ("claude", "Claude"),
59
+ ("antigravity", "Gemini"),
60
+ ("gemini", "Gemini"),
61
+ )
62
+
63
+ READS = ToolAnnotations(readOnlyHint=True, openWorldHint=False)
64
+ # it shows something in the browser, and changes nothing else
65
+ PROPOSES = ToolAnnotations(
66
+ readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=False
67
+ )
68
+
69
+ # the tools, by name
70
+ TOOLS = (
71
+ "list_corpora",
72
+ "corpus_guide",
73
+ "describe_corpus",
74
+ "list_features",
75
+ "describe_feature",
76
+ "search_syntax_guide",
77
+ "check_template",
78
+ "try_search",
79
+ "propose_search",
80
+ )
81
+
82
+
83
+ def build_server(call: Call, name: str = "CACCHT Explorer") -> FastMCP:
84
+ server = FastMCP(
85
+ name,
86
+ instructions=INSTRUCTIONS,
87
+ stateless_http=True,
88
+ json_response=True,
89
+ streamable_http_path="/mcp",
90
+ transport_security=TransportSecuritySettings(
91
+ enable_dns_rebinding_protection=True,
92
+ allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"],
93
+ # no web page may call it, not even the browser's own
94
+ allowed_origins=[],
95
+ ),
96
+ )
97
+
98
+ @server.tool(annotations=READS)
99
+ async def list_corpora() -> dict[str, Any]:
100
+ """The corpora open in the browser: their names (to pass to the other
101
+ tools), titles, ids, and section types (book, chapter, verse)."""
102
+ return await call("list_corpora", {})
103
+
104
+ @server.tool(annotations=READS)
105
+ async def corpus_guide(corpus: str) -> dict[str, Any]:
106
+ """A guide to searching this corpus: its object types, its main
107
+ features in plain words, conventions (such as how lexemes are
108
+ written), worked examples and common mistakes. Read it first."""
109
+ return await call("corpus_guide", {"corpus": corpus})
110
+
111
+ @server.tool(annotations=READS)
112
+ async def describe_corpus(corpus: str) -> dict[str, Any]:
113
+ """The corpus' object types (word, phrase, clause, verse, ...) with
114
+ their numbers, and its sections."""
115
+ return await call("describe_corpus", {"corpus": corpus})
116
+
117
+ @server.tool(annotations=READS)
118
+ async def list_features(
119
+ corpus: str, node_types: list[str] | None = None
120
+ ) -> dict[str, Any]:
121
+ """The features of the corpus, with descriptions; only those of
122
+ `node_types` (such as ["word"]) if given."""
123
+ return await call("list_features", {"corpus": corpus, "node_types": node_types})
124
+
125
+ @server.tool(annotations=READS)
126
+ async def describe_feature(corpus: str, features: list[str]) -> dict[str, Any]:
127
+ """Features in detail: their most frequent values with counts, so
128
+ that a template uses values the corpus has."""
129
+ return await call("describe_feature", {"corpus": corpus, "features": features})
130
+
131
+ @server.tool(annotations=READS)
132
+ async def search_syntax_guide(section: str | None = None) -> dict[str, Any]:
133
+ """The search template language: objects on lines, indentation for
134
+ what lies inside what, features, relations and quantifiers. Without
135
+ `section`, an overview with the sections there are."""
136
+ return await call("search_syntax_guide", {"section": section})
137
+
138
+ @server.tool(annotations=READS)
139
+ async def check_template(corpus: str, template: str) -> dict[str, Any]:
140
+ """Check a template without searching: its problems (syntax, unknown
141
+ object types or features), each with its line; `ok` when none."""
142
+ return await call("check_template", {"corpus": corpus, "template": template})
143
+
144
+ @server.tool(annotations=READS)
145
+ async def try_search(
146
+ corpus: str, template: str, examples: int = 5
147
+ ) -> dict[str, Any]:
148
+ """Run a template as a trial: the number of results (`atLeast` when
149
+ there are more than the trial counts), a few examples with their
150
+ passage and text, and for 0 results hints such as a value a feature
151
+ never has. For checking your template, not as the answer."""
152
+ return await call(
153
+ "try_search", {"corpus": corpus, "template": template, "examples": examples}
154
+ )
155
+
156
+ @server.tool(annotations=PROPOSES)
157
+ async def propose_search(
158
+ corpus: str,
159
+ template: str,
160
+ request: str,
161
+ explanation: str,
162
+ model: str | None = None,
163
+ ctx: Context | None = None,
164
+ ) -> dict[str, Any]:
165
+ """Show the search in the user's browser, where they run it with Run,
166
+ edit it, or dismiss it. `request`: what the user asked, in their own
167
+ words; `explanation`: what each line of the template does, briefly;
168
+ `model`: the name of the AI app or model, as the user should see it
169
+ (such as "Claude" or "ChatGPT").
170
+ A template with problems is not shown: they come back to be fixed."""
171
+ return await call(
172
+ "propose_search",
173
+ {
174
+ "corpus": corpus,
175
+ "template": template,
176
+ "request": request,
177
+ "explanation": explanation,
178
+ "model": model or client_name(ctx),
179
+ },
180
+ )
181
+
182
+ return server
183
+
184
+
185
+ def readable(name: str) -> str | None:
186
+ """The name of an app as the user knows it ("codex-mcp-client" is
187
+ Codex), None for one we don't know."""
188
+ lowered = name.lower()
189
+ for part, known in CLIENTS:
190
+ if part in lowered:
191
+ return known
192
+ return None
193
+
194
+
195
+ def client_name(ctx: Context | None) -> str | None:
196
+ """The name of the app that called, for a proposal without `model`: from
197
+ the stdio helper's header, else the User-Agent."""
198
+ try:
199
+ request = ctx.request_context.request if ctx is not None else None
200
+ except ValueError: # not in a request
201
+ return None
202
+ if request is None:
203
+ return None
204
+ headers = request.headers
205
+ return readable(headers.get(CLIENT_HEADER) or headers.get("user-agent") or "")
206
+
207
+
208
+ def local(tools: Any) -> Call:
209
+ """Calls on the browser's own tools (for `/mcp`), in a thread: a trial
210
+ search waits for the pool."""
211
+ from cfabric_browser.assistant.tools import ToolError
212
+
213
+ async def call(name: str, args: dict[str, Any]) -> Any:
214
+ if name not in TOOLS:
215
+ raise ValueError(f"No tool {name}")
216
+
217
+ def run() -> Any:
218
+ try:
219
+ return getattr(tools, name)(**args)
220
+ except ToolError as e:
221
+ return {"ok": False, "error": str(e)}
222
+
223
+ return await to_thread.run_sync(run)
224
+
225
+ return call
@@ -0,0 +1,99 @@
1
+ """Searches proposed by a language model, kept until the user runs or
2
+ dismisses them.
3
+
4
+ A proposal has the user's request in their own words, the template, the
5
+ model's explanation, which model made it and when, and the trial's count.
6
+ The open page follows the proposals live (`/api/proposals/events`); nothing
7
+ runs until the user presses *Run*.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import datetime
13
+ import threading
14
+ import uuid
15
+ from dataclasses import asdict, dataclass, field
16
+ from typing import Any
17
+
18
+ NEW = "new"
19
+ RUN = "run"
20
+ DISMISSED = "dismissed"
21
+ STATES = (NEW, RUN, DISMISSED)
22
+ KEEP = 50 # proposals kept, the newest
23
+
24
+
25
+ @dataclass
26
+ class Proposal:
27
+ id: str
28
+ corpus: str
29
+ template: str
30
+ request: str
31
+ explanation: str
32
+ model: str | None
33
+ created: str
34
+ # the trial before proposing: its count, and whether that is a lower bound
35
+ count: int | None = None
36
+ atLeast: bool = False
37
+ state: str = NEW
38
+
39
+
40
+ @dataclass
41
+ class ProposalStore:
42
+ proposals: list[Proposal] = field(default_factory=list)
43
+ version: int = 0
44
+ _cond: threading.Condition = field(default_factory=threading.Condition, repr=False)
45
+
46
+ def add(
47
+ self,
48
+ corpus: str,
49
+ template: str,
50
+ request: str,
51
+ explanation: str,
52
+ model: str | None = None,
53
+ count: int | None = None,
54
+ atLeast: bool = False,
55
+ ) -> dict[str, Any]:
56
+ proposal = Proposal(
57
+ id=uuid.uuid4().hex[:12],
58
+ corpus=corpus,
59
+ template=template,
60
+ request=request,
61
+ explanation=explanation,
62
+ model=model,
63
+ created=datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds"),
64
+ count=count,
65
+ atLeast=atLeast,
66
+ )
67
+ with self._cond:
68
+ self.proposals.append(proposal)
69
+ del self.proposals[:-KEEP]
70
+ self.version += 1
71
+ self._cond.notify_all()
72
+ return asdict(proposal)
73
+
74
+ def set_state(self, pid: str, state: str) -> dict[str, Any] | None:
75
+ if state not in STATES:
76
+ raise ValueError(f"Unknown state {state!r}")
77
+ with self._cond:
78
+ for p in self.proposals:
79
+ if p.id == pid:
80
+ p.state = state
81
+ self.version += 1
82
+ self._cond.notify_all()
83
+ return asdict(p)
84
+ return None
85
+
86
+ def snapshot(self) -> dict[str, Any]:
87
+ """All proposals, the newest first, and the version they are at."""
88
+ with self._cond:
89
+ return {
90
+ "version": self.version,
91
+ "proposals": [asdict(p) for p in reversed(self.proposals)],
92
+ }
93
+
94
+ def wait_change(self, version: int, timeout: float) -> int:
95
+ """Block until the proposals change after `version`, or the timeout."""
96
+ with self._cond:
97
+ if self.version == version:
98
+ self._cond.wait(timeout)
99
+ return self.version