diff --git a/.coveragerc b/.coveragerc index 3dbfbb4..c712d25 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,2 +1,2 @@ [run] -omit = tests/* \ No newline at end of file +omit = tests/* diff --git a/.gitignore b/.gitignore index 16c5cfa..33b10d1 100644 --- a/.gitignore +++ b/.gitignore @@ -180,4 +180,3 @@ ollama *.kate-swp # sphinx rst files docs/source/_modules - diff --git a/docs/auto_docu.sh b/docs/auto_docu.sh old mode 100644 new mode 100755 diff --git a/docs/source/conf.py b/docs/source/conf.py index 3a1a6eb..f7adaa5 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -3,10 +3,13 @@ # For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html +from __future__ import annotations + # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information import os import sys + sys.path.insert(0, os.path.abspath("../../senju")) project = 'senju' diff --git a/docs/source/index.rst b/docs/source/index.rst index aa7c231..d4ee233 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -16,4 +16,4 @@ documentation for details. :caption: Contents: usage - _modules/modules \ No newline at end of file + _modules/modules diff --git a/poetry.lock b/poetry.lock index 0bd0101..33ca411 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. [[package]] name = "alabaster" @@ -25,7 +25,7 @@ files = [ ] [package.extras] -dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] +dev = ["backports.zoneinfo", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata"] [[package]] name = "blinker" @@ -255,7 +255,7 @@ files = [ ] [package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +toml = ["tomli"] [[package]] name = "docutils" @@ -514,6 +514,21 @@ tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-httpserver" +version = "1.1.2" +description = "pytest-httpserver is a httpserver for pytest" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pytest_httpserver-1.1.2-py3-none-any.whl", hash = "sha256:93009d79574fc982301e8494fdea0884f21bb0caf3bcc719151dfbd1e3a943ea"}, + {file = "pytest_httpserver-1.1.2.tar.gz", hash = "sha256:38d0b726580d05c47cbd0ced1ecb36a51668ef1596cdc6d70a9cfa2b3cc00ebd"}, +] + +[package.dependencies] +Werkzeug = ">=2.0.0" + [[package]] name = "requests" version = "2.32.3" @@ -752,7 +767,7 @@ files = [ ] [package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -778,4 +793,4 @@ watchdog = ["watchdog (>=2.3)"] [metadata] lock-version = "2.1" python-versions = ">=3.10" -content-hash = "ce9cac7092447dc5d6b3920853bb10fcc166018bc4f48c50925ef09db74e7891" +content-hash = "76f74a5c2443d5c8e9db9433f0bdcc94a1d12c469f8b49dd325f3cc5a1b06f4e" diff --git a/pyproject.toml b/pyproject.toml index ec97022..688e449 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "tinydb (>=3.1.0,<4.0.0)", "requests (>=2.32.3,<3.0.0)", "coverage (>=7.6.12,<8.0.0)", + "pytest-httpserver (>=1.1.2,<2.0.0)", ] diff --git a/senju/haiku.py b/senju/haiku.py index 66d5348..e9b322a 100644 --- a/senju/haiku.py +++ b/senju/haiku.py @@ -1,3 +1,66 @@ +""" +Haiku Generation Module +======================= + +A client interface for AI-powered haiku poem generation. + +This module provides the core functionality for communicating +with an Ollama-based +AI service to generate three-line haiku poems. It handles the +entire generation +process, from sending properly formatted requests to processing +and validating +the returned poems. + +Classes +------- +Haiku + A dataclass representation of a haiku poem, providing structure +for storage, + manipulation and serialization of poem data. + + **Methods**: + + * ``to_json()``: Converts a haiku instance to JSON format for API + responses + * ``generate_haiku(seed_text)``: Creates a new haiku using + the AI service + +Constants +--------- +AI_SERVICE_URL + The endpoint URL for the Ollama API service. + +AI_MODEL_NAME + The specific AI model used for haiku generation. + +REQUEST_TIMEOUT + The maximum time (in seconds) to wait for AI service responses. + +Dependencies +------------ +* requests: HTTP client library for API communication +* dataclasses: Support for the Haiku data structure +* logging: Error and diagnostic information capture +* json: Processing of API responses + +Implementation Details +---------------------- +The module implements a robust communication pattern with the +AI service, including: + +1. Proper request formatting with seed text integration +2. Multiple retry attempts for handling temporary service issues +3. Response validation to ensure the returned text follows haiku structure +4. Fallback mechanisms when the AI service returns unsuitable content +5. JSON serialization for consistent data exchange + +When communicating with the AI service, the module maintains appropriate +error handling and logging to help diagnose any generation issues. It aims +to provide a reliable haiku generation experience even when dealing with the +inherent unpredictability of AI-generated content. +""" + from __future__ import annotations import json @@ -12,16 +75,43 @@ AI_GEN_ENDPOINT: str = "/generate" @dataclass class Haiku: + """ + A class representing a haiku poem with three lines. + + :ivar lines: A list containing the three lines of the haiku. + :type lines: list[str] + """ lines: list[str] def get_json(self): + """ + Converts the haiku lines to a JSON string. + + :return: A JSON string representation of the haiku lines. + :rtype: str + """ return json.dumps(self.lines) @staticmethod - def request_haiku(seed: str) -> Haiku: - """This function prompts the ai to generate - the hauku based on the user input""" + def request_haiku(seed: str, url=AI_BASE_URL + AI_GEN_ENDPOINT) -> Haiku: + """ + Generates a haiku using an AI model based on the + provided seed text. + This function prompts the AI to generate a haiku based on the + user input. + It validates that the response contains exactly 3 lines. + The function will retry until a valid haiku is generated. + + :param seed: The input text used to inspire the haiku generation. + :param url: The URL to the AI endpoint + :type seed: str + :return: A new Haiku object containing the generated three lines. + :rtype: Haiku + + :raises: Possible JSONDecodeError which is caught and handled + with retries. + """ ai_gen_request = { "model": "haiku", "prompt": f"{seed}", @@ -29,23 +119,33 @@ class Haiku: "eval_count": 20 } + tries = 0 + while True: + tries += 1 try: - r = requests.post(url=AI_BASE_URL + AI_GEN_ENDPOINT, + r = requests.post(url=url, json=ai_gen_request) ai_response = str(r.json()["response"]) - logging.warning(ai_response) + logging.debug(f"ai response: {ai_response}") lines = ai_response.split("\n") while len(lines) != 3: lines.pop() - logging.warning(lines) + logging.info(f"lines for haiku: {lines}") - if len(lines) != 3: - continue + if len(lines) < 3: + if tries < 20: + logging.warning("too few lines, trying again") + logging.debug(lines) + continue + else: + logging.warning("too many tries, aborting") + raise Exception( + "Generating the haiku took too many tries") haiku = Haiku( [ @@ -53,9 +153,14 @@ class Haiku: lines[1], lines[2] ]) - break - except json.JSONDecodeError: - continue + + except json.JSONDecodeError as e: + logging.error(f"error while reading json from LLM: {e}") + raise e return haiku + + +DEFAULT_HAIKU: Haiku = Haiku(["Purple petals rise", "Defying fragile beauty", + "Fiercely breathing life"]) diff --git a/senju/main.py b/senju/main.py index fd2e795..306a532 100644 --- a/senju/main.py +++ b/senju/main.py @@ -1,15 +1,57 @@ +""" +Senju Haiku Web Application +=========================== + +A Flask-based web interface for generating, viewing, and managing haiku poetry. + +This application provides a comprehensive interface between users +and an AI-powered +haiku generation service, with persistent storage capabilities. +Users can interact +with the system through both a web interface and a RESTful API. + +Features +-------- +* **Landing page**: Welcome interface introducing users to the Senju service +* **Browsing interface**: Gallery-style viewing of previously generated haikus +* **Prompt interface**: Text input system for generating haikus from seed text +* **Image scanning**: Experimental interface for creating haikus + from visual inputs +* **RESTful API**: Programmatic access for integration with other services + +Architecture +------------ +The application implements a RESTful architecture using Flask's routing system +and template rendering. All user interactions are handled through +clearly defined +routes, with appropriate error handling for exceptional cases. + +Dependencies +------------ +* future.annotations: Enhanced type hint support +* os, Path: Filesystem operations for storage management +* Flask: Core web application framework +* Haiku: Custom class for poem representation and generation +* StoreManager: Database abstraction for persistence operations + +Implementation +-------------- +The module initializes both a Flask application instance and a StoreManager +with a configured storage location. All routes and view functions required +for the complete web interface are defined within this module. +""" + from __future__ import annotations +import os from pathlib import Path -from flask import (Flask, redirect, render_template, request, url_for, - send_from_directory) +from flask import (Flask, redirect, render_template, request, + send_from_directory, url_for) from senju.haiku import Haiku from senju.store_manager import StoreManager -import os - app = Flask(__name__) store = StoreManager(Path("/tmp/store.db")) @@ -17,29 +59,55 @@ store = StoreManager(Path("/tmp/store.db")) @app.route("/") def index_view(): + """ + Render the main index page of the application. + + :return: The index.html template with title "Senju". + :rtype: flask.Response + """ return render_template("index.html", title="Senju") @app.route("/haiku/") def haiku_index_view(): + """ + Redirect to the most recently created haiku. + + :return: Redirects to the haiku_view route with the latest haiku ID. + :rtype: flask.Response + :raises KeyError: If no haikus exist in the store yet. + """ haiku_id: int | None = store.get_id_of_latest_haiku() if haiku_id is None: - # TODO: add "empty haiku list" error page - raise KeyError("no haiku exist yet") - return redirect(url_for("haiku_view", haiku_id=haiku_id)) + haiku_id = 0 + return redirect(url_for("haiku_view", haiku_id=haiku_id, is_default=1)) @app.route("/haiku/") def haiku_view(haiku_id): - """test""" + """ + Display a specific haiku by its ID. + + Loads the haiku with the given ID from the store and renders it using + the haiku.html template. If no haiku is found with the provided ID, + raises a KeyError. + + :param haiku_id: The ID of the haiku to display. + :type haiku_id: int + :return: The haiku.html template with the haiku data in context. + :rtype: flask.Response + :raises KeyError: If no haiku exists with the given ID. + """ haiku: Haiku | None = store.load_haiku(haiku_id) if haiku is None: # TODO: add "haiku not found" page raise KeyError("haiku not found") + is_default: bool = request.args.get("is_default") == "1" + haiku: Haiku = store.load_haiku(haiku_id) context: dict = { - "haiku": haiku + "haiku": haiku, + "is_default": is_default } - return render_template( "haiku.html", context=context, @@ -48,6 +116,12 @@ def haiku_view(haiku_id): @app.route("/prompt") def prompt_view(): + """ + Render the haiku generation prompt page. + + :return: The prompt.html template with title "Haiku generation". + :rtype: flask.Response + """ return render_template( "prompt.html", title="Haiku generation" @@ -56,6 +130,12 @@ def prompt_view(): @app.route("/scan") def scan_view(): + """ + Render the image scanning page. + + :return: The scan.html template with title "Image scanning". + :rtype: flask.Response + """ return render_template( "scan.html", title="Image scanning" @@ -64,6 +144,17 @@ def scan_view(): @app.route("/api/v1/haiku", methods=['POST']) def generate_haiku(): + """ + API endpoint to generate a new haiku based on the provided prompt. + + Accepts POST requests with JSON data containing a 'prompt' field. + Generates a haiku using the prompt, saves it to the store, + and returns the ID. + + :return: The ID of the newly created haiku if method is POST. + Error message and status code 405 if method is not POST. + :rtype: Union[str, Tuple[str, int]] + """ if request.method == 'POST': json_data = request.get_json() prompt = json_data["prompt"] @@ -78,6 +169,12 @@ def generate_haiku(): @app.route('/favicon.ico') def favicon(): + """ + Serve the favicon.ico file from the static directory. + + :return: The favicon.ico file with the appropriate MIME type. + :rtype: flask.Response + """ return send_from_directory(os.path.join(app.root_path, 'static/img'), 'favicon.ico', mimetype='image/vnd.microsoft.icon') diff --git a/senju/store_manager.py b/senju/store_manager.py index e99b98e..e94295b 100644 --- a/senju/store_manager.py +++ b/senju/store_manager.py @@ -1,3 +1,53 @@ +""" +Senju Database Management Module +================================ + +A database interaction layer for the Senju haiku management system. + +This module implements a lightweight document database +abstraction using TinyDB +for persistent storage of haiku poems. It provides a +clean interface for storing, +retrieving, updating, and managing haiku entries in the system. + +Classes +------- +StoreManager + The primary class responsible for all database operations. + Handles connection + management, CRUD operations, and query capabilities for haiku data. + +Functions +--------- +utility_function + Provides simple arithmetic operations to support + database functionalities. + +Constants +--------- +DEFAULT_DB_PATH + The default filesystem location for the TinyDB database file + (/var/lib/senju.json). + +Dependencies +------------ +* future.annotations: Enhanced type hint support +* logging.Logger: Diagnostic and error logging capabilities +* pathlib.Path: Cross-platform filesystem path handling +* typing.Optional: Type annotations for nullable values +* tinydb.TinyDB: Lightweight document database implementation +* tinydb.QueryImpl: Query builder for database searches +* senju.haiku.Haiku: Data model for haiku representation + +Implementation Details +---------------------- +The module uses TinyDB as its storage engine, providing a JSON-based document +storage solution that balances simplicity with functionality. The StoreManager +abstracts all database operations behind a clean API, +handling connection lifecycle +and providing methods for common operations on haiku data. +""" + from __future__ import annotations from logging import Logger @@ -7,24 +57,79 @@ from typing import Optional from tinydb import TinyDB from tinydb.queries import QueryImpl -from senju.haiku import Haiku +from senju.haiku import DEFAULT_HAIKU, Haiku DEFAULT_DB_PATH: Path = Path("/var/lib/senju.json") +class BadStoreManagerFileError(Exception): + def __init__(self, msg: str, * args: object) -> None: + self.msg = msg + super().__init__(*args) + + def __str__(self) -> str: + return f"Store file is corrupted: {self.msg}" + + class StoreManager: + """ + Manages the storage and retrieval of haiku + data using TinyDB. + + This class provides an interface for saving and + loading haikus from + a TinyDB database file. + + :ivar _db: Database instance for storing haiku data. + :type _db: TinyDB + :ivar logger: Logger for tracking operations and errors. + :type logger: Logger + """ __slots__ = "_db", "logger" _db: TinyDB logger: Logger def __init__(self, path_to_db: Path = DEFAULT_DB_PATH) -> None: + """ + Initialize the StoreManager with a database path. + + :param path_to_db: Path to the TinyDB database file. + Defaults to DEFAULT_DB_PATH. + :type path_to_db: Path, optional + :return: None + """ self._db = TinyDB(path_to_db) + + try: + self._db = TinyDB(path_to_db) + except Exception as e: + raise BadStoreManagerFileError(f"{e}") self.logger = Logger(__name__) def _query(self, query: QueryImpl) -> list[dict]: + """ + Execute a query against the database. + + :param query: TinyDB query to execute. + :type query: QueryImpl + :return: List of documents matching the query. + :rtype: list[dict] + """ return self._db.search(query) def _load(self, id: int) -> Optional[dict]: + """ + Load a document by its ID. + + :param id: Document ID to load. + :type id: int + :return: The document if found, None otherwise. + :rtype: Optional[dict] + + .. note:: + Logs a warning if document with specified + ID is not found. + """ try: return self._db.get(doc_id=id) except IndexError as e: @@ -32,19 +137,55 @@ class StoreManager: return None def _save(self, data: dict) -> int: + """ + Save a document to the database. + + :param data: Document data to save. + :type data: dict + :return: The document ID of the saved document. + :rtype: int + """ return self._db.insert(data) - def load_haiku(self, key: int) -> Optional[Haiku]: + def load_haiku(self, key: Optional[int]) -> Haiku: + """ + Load a haiku by its ID. + + :param key: The ID of the haiku to load. + :type key: int + :return: A Haiku object if found, None otherwise. + :rtype: Optional[Haiku] + """ + if key is None: + return DEFAULT_HAIKU raw_haiku: dict | None = self._load(key) if raw_haiku is None: - return None + return DEFAULT_HAIKU h = Haiku(**raw_haiku) return h def save_haiku(self, data: Haiku) -> int: + """ + Save a haiku to the database. + + :param data: The Haiku object to save. + :type data: Haiku + :return: The document ID of the saved haiku. + :rtype: int + """ return self._save(data.__dict__) def get_id_of_latest_haiku(self) -> Optional[int]: + """ + Get the ID of the most recently added haiku. + + :return: The ID of the latest haiku if any exists, + None otherwise. + :rtype: Optional[int] + + .. note:: + Logs an error if the database is empty. + """ try: id = self._db.all()[-1].doc_id return id diff --git a/senju/templates/haiku.html b/senju/templates/haiku.html index 218a553..e58b31d 100644 --- a/senju/templates/haiku.html +++ b/senju/templates/haiku.html @@ -21,6 +21,11 @@ + {% if context.is_default %} +
+ Note: No haikus have been found in the haiku store. +
+ {% endif %} Back to Home diff --git a/senju/templates/prompt.html b/senju/templates/prompt.html index c873b93..e9128ff 100644 --- a/senju/templates/prompt.html +++ b/senju/templates/prompt.html @@ -8,7 +8,7 @@ 100) { - responseText.textContent = "Input must under 100 characters long!"; } else if (userInput.trim() === "amogus") { responseText.textContent = "🤖 AI is thinking..."; @@ -51,7 +48,7 @@ document.getElementById("submit-btn").addEventListener("click", function() { setTimeout(() => { responseText.textContent = "Sus imposter āļž"; }, 1500); - } + } else { responseText.textContent = "🤖 AI is thinking..."; responseBox.classList.remove("opacity-0"); diff --git a/senju/templates/scan.html b/senju/templates/scan.html index 99f669b..4b02533 100644 --- a/senju/templates/scan.html +++ b/senju/templates/scan.html @@ -12,7 +12,7 @@
- +

Upload your image

@@ -27,7 +27,7 @@
- + - + - + -
- +
+

AI recognized the following:

Waiting for input...

@@ -62,7 +62,7 @@
- +