qA_Ap.web.api
1import json 2from pathlib import Path 3 4import oyaml 5from pdoc import pdoc 6from bottle import Bottle, response, request, HTTPResponse 7 8from ..state import State 9from ..classes import Document, Note 10from ..settings import SETTINGS 11from ..app.ai.methods import query 12from ..app.catalog import compile_catalog, compile_attribute 13 14server = Bottle() 15""" 16 A Bottle server instance. 17 It is accessible as `qA_Ap.server`. 18""" 19 20 21# ============================================================== Authentication Wrappers 22 23 24post_auth_check = lambda: False 25""" 26A method variable called before each POST request. 27It must return a bool that indicates wether or not the request can be made. 28By default always returns `False` unless `allow_post` is set to `True` in the `qA_Ap.init()` method. 29Redefine this variable to implement your authentication. 30```qA_Ap.server.post_auth_check = your_method``` 31""" 32stream_auth_check = lambda: True 33""" 34A method variable called before each `api/stream` endpoint request. 35It must return a bool that indicates wether or not the request can be made. 36By default always returns `True`. 37Redefine this variable to implement your stream endpoint authentication. 38```qA_Ap.server.stream_auth_check = your_method``` 39""" 40 41def post_auth_wrapper(func): 42 """ 43 A decorator to each POST endpoints. 44 Calls the post_auth_check to checks wheter or not the request is authenticated. 45 """ 46 47 def wrapper(*args, **kwargs): 48 if post_auth_check() == False: 49 return HTTPResponse(status=401, body=json.dumps({"error": "Unauthorized"})) 50 51 return func(*args, **kwargs) 52 53 return wrapper 54 55def stream_auth_wrapper(func): 56 """ 57 A decorator to each the 'api/stream' endpoint. 58 Calls the stream_auth_check to checks wheter or not the request is authenticated. 59 """ 60 61 def wrapper(*args, **kwargs): 62 if stream_auth_check() == False: 63 return HTTPResponse(status=401, body=json.dumps({"error": "Unauthorized"})) 64 65 return func(*args, **kwargs) 66 67 return wrapper 68 69 70# ============================================================ DOCS ENDPOINTS 71 72@server.get('/api/docs') 73def get_docs() -> str: 74 """ 75 # GET `/api/docs` 76 77 Returns the api documentation generated with [**pDoc**](https://pdoc.dev/). 78 79 """ 80 return pdoc(Path(__file__).resolve()) 81 82# ============================================================== GET ENDPOINTS 83 84@server.get('/api/catalog') 85def get_catalog() -> str: 86 """ 87 # GET `/api/catalog` 88 89 Returns the catalog as JSON. 90 91 The catalog is a list of all documents with their metadata and a short excerpt. 92 93 ## 200 response: 94 ```json 95 [ 96 { 97 "title": "Document Title", 98 "metadata": { 99 "links": ["link1", "link2"], 100 "attributes": ["attribute1", "attribute2"], 101 }, 102 "excerpt": "This is a short excerpt of the document..." 103 }, 104 ... 105 ] 106 ``` 107 108 """ 109 response.headers['Content-Type'] = 'application/json' 110 response.headers['Cache-Control'] = 'no-cache' 111 try: 112 return State.Database.get_catalog() 113 except Exception as e: 114 response.status = 500 115 return json.dumps({"error": str(e)}) 116 117@server.get('/api/document/<name>') 118def get_document_by_name(name: str) -> str: 119 """ 120 # GET `/api/document/<name>` 121 122 Returns a document by name. 123 Returns an error message if the document is not found or if an error occurs. 124 125 ## 200 response: 126 ```json 127 { 128 "title": "Document Title", 129 "content": "Full content of the document...", 130 "metadata": { 131 "links": ["link1", "link2"], 132 "attributes": ["attribute1", "attribute2"], 133 } 134 } 135 ``` 136 137 ## Error response: 138 ```json 139 {"error": "<error message>"} 140 ``` 141 """ 142 response.headers['Content-Type'] = 'application/json' 143 response.headers['Cache-Control'] = 'no-cache' 144 try: 145 document, icon = State.Database.get_document(name) 146 return json.dumps(Document.from_text(name, document).dict) 147 except Exception as e: 148 response.status = 404 149 return json.dumps({"error": str(e)}) 150 151@server.get('/api/notes/<post_title>') 152def get_notes_for_post(post_title: str) -> str: 153 """ 154 # GET `/api/notes/<post_title>` 155 156 Returns all notes for a document as JSON. 157 158 ## 200 response: 159 ```json 160 [ 161 { 162 "note_title": "User1", 163 "content": "Note content...", 164 "metadata": { 165 "rating": 5, 166 } 167 }, 168 ... 169 ] 170 ``` 171 172 ## Error response: 173 ```json 174 {"error": "<error message>"} 175 ``` 176 """ 177 response.headers['Content-Type'] = 'application/json' 178 response.headers['Cache-Control'] = 'no-cache' 179 try: 180 notes = State.Database.get_notes_for_post(post_title) 181 return json.dumps([Note.from_text(note_title, document, content).dict for (content, document, note_title) in notes]) 182 except Exception as e: 183 response.status = 500 184 return json.dumps({"error": str(e)}) 185 186@server.get('/api/attributes/<attribute_name>') 187def get_attributes(attribute_name: str) -> str: 188 """ 189 # GET `/api/attributes/<attribute_name>` 190 191 Returns all existing values for the specified attribute as JSON. 192 193 ## 200 response: 194 ```json 195 [ 196 "attribute1", 197 "attribute2", 198 ... 199 ] 200 ``` 201 202 ## Error response: 203 ```json 204 {"error": "<error message>"} 205 ``` 206 """ 207 response.headers['Content-Type'] = 'application/json' 208 try: 209 cats = State.Database.get_attribute_values(attribute_name).split("\n") 210 return json.dumps(cats) 211 except Exception as e: 212 response.status = 500 213 return json.dumps({"error": str(e)}) 214 215 216# ============================================================== POST ENDPOINTS 217 218@server.post('/api/document') 219@post_auth_wrapper 220def post_post() -> str: 221 """ 222 # POST `/api/document` 223 224 Registers a new document. 225 226 ## Request body: 227 ```json 228 { 229 "title": <str>, 230 "medias": <list[str]>, 231 "content": <yaml str> | <str> 232 "metadata": <dict[str,str]> 233 } 234 ``` 235 236 If no `metadata` field is given the document content can be in YAML format with optional metadata prefixed. 237 238 ## Document content example with metadata: 239 ```yaml 240 medias: 241 - image1.png 242 - image2.png 243 tags: 244 - tag1 245 - tag2 246 ### 247 248 This is the text content of the document. 249 ``` 250 251 If the `metadata` field is given, the yaml formatted fields are prefixed to the content automatically. 252 253 ## 200 response: 254 ```json 255 { 256 "success": true, 257 "message": "The document <post_title> is created" 258 } 259 ``` 260 261 ## Error response: 262 ```json 263 {"error": "<error message>"} 264 ``` 265 """ 266 response.headers['Content-Type'] = 'application/json' 267 try: 268 data = request.json 269 required = ["title", "content"] 270 if not data or not all(k in data for k in required): 271 response.status = 400 272 return json.dumps({"error": "Missing required fields '[title, content]'"}) 273 title = data["title"] 274 content = data["content"] 275 metadatas = data.get("metadata", False) 276 if metadatas: 277 yaml_metas = oyaml.safe_dump(metadatas) 278 content = f"{yaml_metas}\n###\n\n{content}" 279 State.Database.write_post(title, content, data.get("medias", [])) 280 return json.dumps({"message": f"The document {title} is created"}) 281 except Exception as e: 282 response.status = 500 283 return json.dumps({"error": str(e)}) 284 285 286@server.post('/api/note') 287@post_auth_wrapper 288def post_comment() -> str: 289 """ 290 # POST `/api/note` 291 292 Registers a new note. 293 294 ## Request body: 295 ```json 296 { 297 "post_title": "Document Title", 298 "note_title": "User1", 299 "content": <yaml str> | <str> 300 "medias": <list[str]>, 301 "metadata": <dict[str,str]>" 302 } 303 ``` 304 305 ## 200 response: 306 ```json 307 { 308 "success": true, 309 "message": "<note_title> commented on the document <post_title>" 310 } 311 ``` 312 313 ## Error response: 314 ```json 315 {"error": "<error message>"} 316 ``` 317 """ 318 response.headers['Content-Type'] = 'application/json' 319 320 try: 321 data = request.json 322 required = ["post_title", "note_title", "content"] 323 324 if not data or not all(k in data for k in required): 325 response.status = 400 326 return json.dumps({"error": "Missing required fields '[post_title, note_title, content]'"}) 327 328 post_title = data["post_title"] 329 note_title = data["note_title"] 330 content = data["content"] 331 metadatas = data.get("metadata", False) 332 333 if metadatas: 334 yaml_metas = oyaml.safe_dump(metadatas) 335 content = f"{yaml_metas}\n###\n\n{content}" 336 337 State.Database.write_comment( 338 post_title = post_title, 339 note_title = note_title, 340 content = content, 341 medias = data.get("medias", []) 342 ) 343 344 return json.dumps({"message": f"{note_title} commented on the document {post_title}"}) 345 346 except Exception as e: 347 response.status = 500 348 return json.dumps({"error": str(e)}) 349 350 351@server.post('/api/attribute') 352@post_auth_wrapper 353def post_attribute() -> str: 354 """ 355 # POST `/api/attribute` 356 357 Registers new attribute values. 358 All the attribute values already existing are ignored. 359 If the attribute type does not exist, it is created with the new values. 360 361 ## Request body: 362 ```json 363 { 364 "attribute": str, // the attribute name 365 "values": list[str] 366 } 367 ``` 368 369 ## 200 response: 370 ```json 371 { 372 "success": true, 373 "message": "attribute '<attribute>' registered" 374 } 375 ``` 376 377 ## Error response: 378 ```json 379 {"error": "<error message>"} 380 ``` 381 """ 382 response.headers['Content-Type'] = 'application/json' 383 try: 384 data = request.json 385 386 required = ["attribute", "values"] 387 if not data or not all(k in data for k in required): 388 response.status = 400 389 return json.dumps({"error": "Missing required fields '[attribute, values]'"}) 390 391 attribute = data["attribute"] 392 values = data.get("values", []) 393 394 State.Database.add_attribute_values(attribute, values) 395 396 return json.dumps({"message": f"attribute '{attribute}' registered"}) 397 398 except Exception as e: 399 response.status = 500 400 return json.dumps({"error": str(e)}) 401 402@server.post('/api/compile/catalog') 403@post_auth_wrapper 404def post_compile_catalog() -> str: 405 """ 406 # POST `/api/compile/catalog` 407 408 Triggers a rebuild of the catalog from all documents. 409 410 ## 200 response: 411 ```json 412 { 413 "success": true, 414 "message": "Catalog compiled successfully" 415 } 416 ``` 417 418 ## Error response: 419 ```json 420 {"error": "<error message>"} 421 ``` 422 """ 423 response.headers['Content-Type'] = 'application/json' 424 try: 425 compile_catalog() 426 return json.dumps({"success": True, "message": "Catalog compiled successfully"}) 427 except Exception as e: 428 response.status = 500 429 return json.dumps({"error": str(e)}) 430 431@server.post('/api/compile/attribute') 432@post_auth_wrapper 433def post_compile_attribute() -> str: 434 """ 435 # POST `/api/compile/attribute` 436 437 Triggers a rebuild of an attribute values list from the catalog. 438 439 440 ## Request body: 441 ```json 442 { 443 "attributes": <list[str]> 444 } 445 ``` 446 447 ## 200 response: 448 ```json 449 { 450 "success": true, 451 "message": "Catalog compiled successfully" 452 } 453 ``` 454 455 ## Error response: 456 ```json 457 {"error": "<error message>"} 458 ``` 459 """ 460 data = request.json 461 462 if not ("attributes" in data and isinstance(data["attribute"],list)): 463 response.status = 400 464 return json.dumps({"error": "Missing required fields '[attributes]' (list[str])"}) 465 466 response.headers['Content-Type'] = 'application/json' 467 try: 468 for attribute in data["attributes"]: 469 compile_attribute(attribute) 470 return json.dumps({"success": True, "message": f"Attribute '{attribute}' compiled successfully"}) 471 except Exception as e: 472 response.status = 500 473 return json.dumps({"error": str(e)}) 474 475@server.post('/api/query') 476@stream_auth_wrapper 477def stream(): 478 """ 479 # POST `/api/query` 480 481 Streams a response from the LLM for a given prompt. 482 483 The history is a list of entry containing the role and content of messages. 484 485 Example: 486 ```json 487 [ 488 { 489 "role": "user", 490 "content": "Why is the sky blue?" 491 }, 492 { 493 "role": "assistant", 494 "content": "The sky is blue because of the way the Earth's atmosphere scatters sunlight." 495 }, 496 ... 497 ] 498 ``` 499 500 ## Request body: 501 ```json 502 { 503 "prompt": <str>, 504 "history": <list[dict[str,str]]> (default None) 505 "metadata": <bool> (default: true) // whether to include retrieved documents metadata in the response 506 } 507 ``` 508 509 ## Streamed response: 510 ``` 511 <LLM response chunks> 512 ``` 513 """ 514 515 prompt = request.json.get('prompt', '') 516 history = request.json.get('history', None) 517 include_metadata = request.json.get('metadata', False) 518 519 if not prompt: 520 response.status = 400 521 return json.dumps({"error": "Missing required field 'prompt'"}) 522 523 stream = query(prompt,history,include_metadata) 524 525 response.headers['Content-Type'] = 'text/event-stream' 526 527 if include_metadata: 528 yield f"{"#METADATA#"}{json.dumps(stream.metadatas)}" 529 530 for chunk in stream: 531 if chunk: 532 yield chunk 533 534 535__all__ = [ 536 'server', 537 'get_docs', 538 'get_catalog', 539 'get_document_by_name', 540 'get_notes_for_post', 541 'get_attributes', 542 'post_post', 543 'post_comment', 544 'post_attribute', 545 'stream', 546 'post_auth_check', 547 'stream_auth_check' 548] 549 550if __name__ == '__main__': 551 server.run(host='0.0.0.0', port=8080)
73@server.get('/api/docs') 74def get_docs() -> str: 75 """ 76 # GET `/api/docs` 77 78 Returns the api documentation generated with [**pDoc**](https://pdoc.dev/). 79 80 """ 81 return pdoc(Path(__file__).resolve())
GET /api/docs
Returns the api documentation generated with pDoc.
85@server.get('/api/catalog') 86def get_catalog() -> str: 87 """ 88 # GET `/api/catalog` 89 90 Returns the catalog as JSON. 91 92 The catalog is a list of all documents with their metadata and a short excerpt. 93 94 ## 200 response: 95 ```json 96 [ 97 { 98 "title": "Document Title", 99 "metadata": { 100 "links": ["link1", "link2"], 101 "attributes": ["attribute1", "attribute2"], 102 }, 103 "excerpt": "This is a short excerpt of the document..." 104 }, 105 ... 106 ] 107 ``` 108 109 """ 110 response.headers['Content-Type'] = 'application/json' 111 response.headers['Cache-Control'] = 'no-cache' 112 try: 113 return State.Database.get_catalog() 114 except Exception as e: 115 response.status = 500 116 return json.dumps({"error": str(e)})
GET /api/catalog
Returns the catalog as JSON.
The catalog is a list of all documents with their metadata and a short excerpt.
200 response:
[
{
"title": "Document Title",
"metadata": {
"links": ["link1", "link2"],
"attributes": ["attribute1", "attribute2"],
},
"excerpt": "This is a short excerpt of the document..."
},
...
]
118@server.get('/api/document/<name>') 119def get_document_by_name(name: str) -> str: 120 """ 121 # GET `/api/document/<name>` 122 123 Returns a document by name. 124 Returns an error message if the document is not found or if an error occurs. 125 126 ## 200 response: 127 ```json 128 { 129 "title": "Document Title", 130 "content": "Full content of the document...", 131 "metadata": { 132 "links": ["link1", "link2"], 133 "attributes": ["attribute1", "attribute2"], 134 } 135 } 136 ``` 137 138 ## Error response: 139 ```json 140 {"error": "<error message>"} 141 ``` 142 """ 143 response.headers['Content-Type'] = 'application/json' 144 response.headers['Cache-Control'] = 'no-cache' 145 try: 146 document, icon = State.Database.get_document(name) 147 return json.dumps(Document.from_text(name, document).dict) 148 except Exception as e: 149 response.status = 404 150 return json.dumps({"error": str(e)})
GET /api/document/<name>
Returns a document by name. Returns an error message if the document is not found or if an error occurs.
200 response:
{
"title": "Document Title",
"content": "Full content of the document...",
"metadata": {
"links": ["link1", "link2"],
"attributes": ["attribute1", "attribute2"],
}
}
Error response:
{"error": "<error message>"}
152@server.get('/api/notes/<post_title>') 153def get_notes_for_post(post_title: str) -> str: 154 """ 155 # GET `/api/notes/<post_title>` 156 157 Returns all notes for a document as JSON. 158 159 ## 200 response: 160 ```json 161 [ 162 { 163 "note_title": "User1", 164 "content": "Note content...", 165 "metadata": { 166 "rating": 5, 167 } 168 }, 169 ... 170 ] 171 ``` 172 173 ## Error response: 174 ```json 175 {"error": "<error message>"} 176 ``` 177 """ 178 response.headers['Content-Type'] = 'application/json' 179 response.headers['Cache-Control'] = 'no-cache' 180 try: 181 notes = State.Database.get_notes_for_post(post_title) 182 return json.dumps([Note.from_text(note_title, document, content).dict for (content, document, note_title) in notes]) 183 except Exception as e: 184 response.status = 500 185 return json.dumps({"error": str(e)})
GET /api/notes/<post_title>
Returns all notes for a document as JSON.
200 response:
[
{
"note_title": "User1",
"content": "Note content...",
"metadata": {
"rating": 5,
}
},
...
]
Error response:
{"error": "<error message>"}
187@server.get('/api/attributes/<attribute_name>') 188def get_attributes(attribute_name: str) -> str: 189 """ 190 # GET `/api/attributes/<attribute_name>` 191 192 Returns all existing values for the specified attribute as JSON. 193 194 ## 200 response: 195 ```json 196 [ 197 "attribute1", 198 "attribute2", 199 ... 200 ] 201 ``` 202 203 ## Error response: 204 ```json 205 {"error": "<error message>"} 206 ``` 207 """ 208 response.headers['Content-Type'] = 'application/json' 209 try: 210 cats = State.Database.get_attribute_values(attribute_name).split("\n") 211 return json.dumps(cats) 212 except Exception as e: 213 response.status = 500 214 return json.dumps({"error": str(e)})
GET /api/attributes/<attribute_name>
Returns all existing values for the specified attribute as JSON.
200 response:
[
"attribute1",
"attribute2",
...
]
Error response:
{"error": "<error message>"}
48 def wrapper(*args, **kwargs): 49 if post_auth_check() == False: 50 return HTTPResponse(status=401, body=json.dumps({"error": "Unauthorized"})) 51 52 return func(*args, **kwargs)
POST /api/document
Registers a new document.
Request body:
{
"title": <str>,
"medias": <list[str]>,
"content": <yaml str> | <str>
"metadata": <dict[str,str]>
}
If no metadata field is given the document content can be in YAML format with optional metadata prefixed.
Document content example with metadata:
medias:
- image1.png
- image2.png
tags:
- tag1
- tag2
###
This is the text content of the document.
If the metadata field is given, the yaml formatted fields are prefixed to the content automatically.
200 response:
{
"success": true,
"message": "The document <post_title> is created"
}
Error response:
{"error": "<error message>"}
48 def wrapper(*args, **kwargs): 49 if post_auth_check() == False: 50 return HTTPResponse(status=401, body=json.dumps({"error": "Unauthorized"})) 51 52 return func(*args, **kwargs)
POST /api/note
Registers a new note.
Request body:
{
"post_title": "Document Title",
"note_title": "User1",
"content": <yaml str> | <str>
"medias": <list[str]>,
"metadata": <dict[str,str]>"
}
200 response:
{
"success": true,
"message": "<note_title> commented on the document <post_title>"
}
Error response:
{"error": "<error message>"}
48 def wrapper(*args, **kwargs): 49 if post_auth_check() == False: 50 return HTTPResponse(status=401, body=json.dumps({"error": "Unauthorized"})) 51 52 return func(*args, **kwargs)
POST /api/attribute
Registers new attribute values. All the attribute values already existing are ignored. If the attribute type does not exist, it is created with the new values.
Request body:
{
"attribute": str, // the attribute name
"values": list[str]
}
200 response:
{
"success": true,
"message": "attribute '<attribute>' registered"
}
Error response:
{"error": "<error message>"}
62 def wrapper(*args, **kwargs): 63 if stream_auth_check() == False: 64 return HTTPResponse(status=401, body=json.dumps({"error": "Unauthorized"})) 65 66 return func(*args, **kwargs)
POST /api/query
Streams a response from the LLM for a given prompt.
The history is a list of entry containing the role and content of messages.
Example:
[
{
"role": "user",
"content": "Why is the sky blue?"
},
{
"role": "assistant",
"content": "The sky is blue because of the way the Earth's atmosphere scatters sunlight."
},
...
]
Request body:
{
"prompt": <str>,
"history": <list[dict[str,str]]> (default None)
"metadata": <bool> (default: true) // whether to include retrieved documents metadata in the response
}
Streamed response:
<LLM response chunks>