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)
@server.get('/api/docs')
def get_docs() -> str:
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.

@server.get('/api/catalog')
def get_catalog() -> str:
 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..."
        },
        ...
    ]
@server.get('/api/document/<name>')
def get_document_by_name(name: str) -> str:
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>"}
@server.get('/api/notes/<post_title>')
def get_notes_for_post(post_title: str) -> str:
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>"}
@server.get('/api/attributes/<attribute_name>')
def get_attributes(attribute_name: str) -> str:
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>"}
def post_post(*args, **kwargs):
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>"}
def post_comment(*args, **kwargs):
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>"}
def post_attribute(*args, **kwargs):
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>"}
def stream(*args, **kwargs):
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>