From ea09bb4f4f7902c8e42d8fcefad113b37b510eb8 Mon Sep 17 00:00:00 2001 From: Sibilla Date: Thu, 16 Jul 2026 15:57:04 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=AE=20Initial=20commit:=20sibilla=20?= =?UTF-8?q?=E2=80=94=20self-hosted=20Perplexity-like=20AI=20search=20engin?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 ++ .gitignore | 34 +++++ README.md | 74 +++++++++++ pyproject.toml | 54 ++++++++ src/sibilla/__init__.py | 3 + src/sibilla/__main__.py | 6 + src/sibilla/app.py | 31 +++++ src/sibilla/cli.py | 21 +++ static/app.js | 108 +++++++++++++++ static/style.css | 285 ++++++++++++++++++++++++++++++++++++++++ templates/index.html | 47 +++++++ 11 files changed, 671 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/sibilla/__init__.py create mode 100644 src/sibilla/__main__.py create mode 100644 src/sibilla/app.py create mode 100644 src/sibilla/cli.py create mode 100644 static/app.js create mode 100644 static/style.css create mode 100644 templates/index.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fc46cef --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Google AI API Key โ€” ottenila da https://aistudio.google.com/apikey +GOOGLE_API_KEY=your-api-key-here + +# Flask +SECRET_KEY=change-me-to-a-random-string +SIBILLA_HOST=127.0.0.1 +SIBILLA_PORT=5000 +SIBILLA_DEBUG=1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..73c7da5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.eggs/ + +# Virtual environment +.venv/ +venv/ +env/ + +# Environment variables +.env +.env.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Flask +instance/ + +# Testing +.pytest_cache/ +htmlcov/ +.coverage diff --git a/README.md b/README.md new file mode 100644 index 0000000..1d2e08b --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# ๐Ÿ”ฎ Sibilla + +> *L'oracolo AI che cerca nel web e risponde alle tue domande.* +> Self-hosted Perplexity-like search engine. + +**Sibilla** รจ un motore di ricerca AI self-hosted che combina: + +- ๐Ÿง  **Google Gemini API** โ€” modelli linguistici avanzati con grounding su web search +- ๐Ÿ”ง **MCP (Model Context Protocol)** โ€” tool use per estendere le capacitร  dell'AI +- ๐Ÿ” **Web search integrata** โ€” ricerca e sintesi delle fonti in tempo reale +- ๐Ÿ–ฅ๏ธ **Flask web app** โ€” interfaccia pulita e reattiva + +``` +Tu chiedi โ†’ Sibilla cerca nel web โ†’ Sintetizza le fonti โ†’ Ti risponde con citazioni +``` + +## Architettura (pianificata) + +``` +sibilla/ +โ”œโ”€โ”€ src/sibilla/ +โ”‚ โ”œโ”€โ”€ app.py # Flask app factory +โ”‚ โ”œโ”€โ”€ cli.py # Entry point +โ”‚ โ”œโ”€โ”€ search.py # Web search + scraping +โ”‚ โ”œโ”€โ”€ ai.py # Google Gemini API client +โ”‚ โ”œโ”€โ”€ mcp.py # MCP tool integration +โ”‚ โ””โ”€โ”€ prompts.py # Prompt templates +โ”œโ”€โ”€ templates/ +โ”‚ โ””โ”€โ”€ index.html # Interfaccia di ricerca +โ”œโ”€โ”€ static/ +โ”‚ โ”œโ”€โ”€ style.css # Stili +โ”‚ โ””โ”€โ”€ app.js # Logica frontend +โ””โ”€โ”€ tests/ + โ””โ”€โ”€ ... +``` + +## Setup + +```bash +cd sibilla +python3 -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" +``` + +Crea un file `.env` con la tua API key Google: + +``` +GOOGLE_API_KEY=your-api-key-here +``` + +## Uso + +```bash +# Avvia il server +sibilla + +# Oppure +python -m sibilla +``` + +Poi apri http://127.0.0.1:5000 nel browser. + +## Sviluppo + +```bash +pip install -e ".[dev]" +pytest +ruff check src tests +``` + +## Licenza + +MIT diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5f9b079 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,54 @@ +[project] +name = "sibilla" +version = "0.1.0" +description = "sibilla - Self-hosted Perplexity-like AI search engine with Google Gemini + MCP tool use, built with Flask" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "Author" }] +keywords = ["ai", "search", "gemini", "perplexity", "mcp", "llm", "rag", "flask"] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Operating System :: OS Independent", + "Environment :: Web Environment", + "Framework :: Flask", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] +dependencies = [ + "flask>=3.0", + "google-genai>=1.0", + "requests>=2.31", + "python-dotenv>=1.0", + "beautifulsoup4>=4.12", + "markdown>=3.5", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7", + "ruff>=0.5", +] + +[project.scripts] +sibilla = "sibilla.cli:main" + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] +include = ["sibilla*"] + +[tool.setuptools.package-dir] +"" = "src" + +[tool.pytest.ini_options] +addopts = "-q" +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py310" diff --git a/src/sibilla/__init__.py b/src/sibilla/__init__.py new file mode 100644 index 0000000..e491818 --- /dev/null +++ b/src/sibilla/__init__.py @@ -0,0 +1,3 @@ +"""sibilla โ€” L'oracolo AI che risponde alle tue domande cercando nel web.""" + +__version__ = "0.1.0" diff --git a/src/sibilla/__main__.py b/src/sibilla/__main__.py new file mode 100644 index 0000000..36bf50c --- /dev/null +++ b/src/sibilla/__main__.py @@ -0,0 +1,6 @@ +"""Allow running sibilla with `python -m sibilla`.""" + +from sibilla.cli import main + +if __name__ == "__main__": + main() diff --git a/src/sibilla/app.py b/src/sibilla/app.py new file mode 100644 index 0000000..c912c68 --- /dev/null +++ b/src/sibilla/app.py @@ -0,0 +1,31 @@ +"""sibilla web application โ€” Flask app factory.""" + +import os +from flask import Flask, render_template + + +def create_app(testing: bool = False) -> Flask: + """Create and configure the Flask application. + + Args: + testing: If True, configure the app for testing (no .env loading). + """ + app = Flask(__name__) + + app.config.update( + SECRET_KEY=os.environ.get("SECRET_KEY", "dev-secret-key-change-me"), + GOOGLE_API_KEY=os.environ.get("GOOGLE_API_KEY", ""), + ) + + # โ”€โ”€ Routes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + @app.route("/") + def index(): + """Home page โ€” the search interface.""" + return render_template("index.html") + + @app.route("/api/health") + def health(): + """Health check endpoint.""" + return {"status": "ok", "version": __import__("sibilla").__version__} + + return app diff --git a/src/sibilla/cli.py b/src/sibilla/cli.py new file mode 100644 index 0000000..d4fb82f --- /dev/null +++ b/src/sibilla/cli.py @@ -0,0 +1,21 @@ +"""CLI entry point for sibilla.""" + +import os + +from sibilla.app import create_app + + +def main() -> None: + """Run the sibilla web server.""" + host = os.environ.get("SIBILLA_HOST", "127.0.0.1") + port = int(os.environ.get("SIBILLA_PORT", "5000")) + debug = os.environ.get("SIBILLA_DEBUG", "1") == "1" + + app = create_app() + + print(f"๐Ÿ”ฎ Sibilla is awakening at http://{host}:{port}") + app.run(host=host, port=port, debug=debug) + + +if __name__ == "__main__": + main() diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..8d5d6bc --- /dev/null +++ b/static/app.js @@ -0,0 +1,108 @@ +/** + * Sibilla โ€” Frontend search interface + */ + +document.addEventListener('DOMContentLoaded', () => { + const form = document.getElementById('search-form'); + const queryInput = document.getElementById('query'); + const submitBtn = document.getElementById('submit-btn'); + const responseArea = document.getElementById('response-area'); + const loading = document.getElementById('loading'); + const answerDiv = document.getElementById('answer'); + const sourcesDiv = document.getElementById('sources'); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + + const query = queryInput.value.trim(); + if (!query) return; + + // Show response area + loading + responseArea.style.display = 'block'; + loading.style.display = 'flex'; + answerDiv.innerHTML = ''; + sourcesDiv.innerHTML = ''; + submitBtn.disabled = true; + + try { + const res = await fetch('/api/ask', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }), + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || 'Errore sconosciuto'); + } + + const data = await res.json(); + + // Render answer as Markdown + answerDiv.innerHTML = renderMarkdown(data.answer); + + // Render sources + if (data.sources && data.sources.length > 0) { + sourcesDiv.innerHTML = '

๐Ÿ“š Fonti

' + + data.sources.map(s => + ` + ${escapeHtml(s.title)} + ${escapeHtml(s.url)} + ` + ).join(''); + } + + } catch (err) { + answerDiv.innerHTML = `
โš ๏ธ ${escapeHtml(err.message)}
`; + } finally { + loading.style.display = 'none'; + submitBtn.disabled = false; + } + }); + + // Allow Ctrl+Enter to submit + queryInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + form.dispatchEvent(new Event('submit')); + } + }); +}); + +// โ”€โ”€ Tiny Markdown renderer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function renderMarkdown(text) { + if (!text) return ''; + + let html = escapeHtml(text); + + // Headers + html = html.replace(/^### (.+)$/gm, '

$1

'); + html = html.replace(/^## (.+)$/gm, '

$1

'); + html = html.replace(/^# (.+)$/gm, '

$1

'); + + // Bold + italic + html = html.replace(/\*\*\*(.+?)\*\*\*/g, '$1'); + html = html.replace(/\*\*(.+?)\*\*/g, '$1'); + html = html.replace(/\*(.+?)\*/g, '$1'); + + // Inline code + html = html.replace(/`(.+?)`/g, '$1'); + + // Links + html = html.replace(/\[(.+?)\]\((.+?)\)/g, '$1'); + + // Paragraphs (double newlines) + html = html.replace(/\n\n/g, '

'); + html = '

' + html + '

'; + html = html.replace(/

\s*<\/p>/g, ''); + + // Line breaks + html = html.replace(/\n/g, '
'); + + return html; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..2279e92 --- /dev/null +++ b/static/style.css @@ -0,0 +1,285 @@ +/* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + Sibilla โ€” L'Oracolo AI + โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */ + +:root { + --bg: #0f0f14; + --surface: #1a1a24; + --surface-hover: #22222e; + --border: #2a2a3a; + --text: #e8e8ed; + --text-muted: #8888a0; + --accent: #a78bfa; + --accent-glow: #c4b5fd; + --success: #34d399; + --danger: #f87171; + --radius: 12px; + --transition: 0.2s ease; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + display: flex; + justify-content: center; +} + +.container { + max-width: 800px; + width: 100%; + padding: 2rem 1.5rem; + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* โ”€โ”€ Header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +header { + text-align: center; + margin-bottom: 3rem; + padding-top: 3rem; +} + +header h1 { + font-size: 2.8rem; + font-weight: 700; + background: linear-gradient(135deg, var(--accent), var(--accent-glow)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 0.5rem; + letter-spacing: -0.02em; +} + +.subtitle { + color: var(--text-muted); + font-size: 1.05rem; +} + +/* โ”€โ”€ Search Box โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.search-box { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 2rem; +} + +.search-box textarea { + width: 100%; + padding: 1rem 1.25rem; + background: var(--surface); + border: 2px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 1.05rem; + font-family: inherit; + resize: vertical; + min-height: 80px; + transition: border-color var(--transition), box-shadow var(--transition); + outline: none; +} + +.search-box textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(167, 139, 250, 0.15); +} + +.search-box textarea::placeholder { + color: var(--text-muted); +} + +#submit-btn { + align-self: flex-end; + padding: 0.75rem 1.75rem; + background: linear-gradient(135deg, var(--accent), #8b5cf6); + border: none; + border-radius: var(--radius); + color: #fff; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: transform var(--transition), box-shadow var(--transition), opacity var(--transition); +} + +#submit-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 20px rgba(167, 139, 250, 0.35); +} + +#submit-btn:active { + transform: translateY(0); +} + +#submit-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +/* โ”€โ”€ Response Area โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.response-area { + flex: 1; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* โ”€โ”€ Loading โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.loading { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 2rem; + color: var(--text-muted); +} + +.spinner { + width: 36px; + height: 36px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* โ”€โ”€ Answer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.answer { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.5rem; + margin-bottom: 1.5rem; + line-height: 1.7; + font-size: 1.02rem; +} + +.answer h1, .answer h2, .answer h3 { + margin-top: 1.25rem; + margin-bottom: 0.5rem; + color: var(--accent-glow); +} + +.answer h1 { font-size: 1.5rem; } +.answer h2 { font-size: 1.25rem; } +.answer h3 { font-size: 1.1rem; } + +.answer p { + margin-bottom: 0.75rem; +} + +.answer ul, .answer ol { + padding-left: 1.5rem; + margin-bottom: 0.75rem; +} + +.answer li { + margin-bottom: 0.35rem; +} + +.answer code { + background: rgba(167, 139, 250, 0.1); + padding: 0.15rem 0.4rem; + border-radius: 4px; + font-size: 0.9em; +} + +.answer pre { + background: rgba(0, 0, 0, 0.3); + padding: 1rem; + border-radius: 8px; + overflow-x: auto; + margin-bottom: 0.75rem; +} + +.answer blockquote { + border-left: 3px solid var(--accent); + padding-left: 1rem; + color: var(--text-muted); + margin-bottom: 0.75rem; +} + +/* โ”€โ”€ Sources โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.sources { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; +} + +.sources h3 { + color: var(--accent-glow); + font-size: 0.95rem; + margin-bottom: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.source-item { + display: block; + padding: 0.6rem 0.75rem; + border-radius: 8px; + color: var(--text); + text-decoration: none; + transition: background var(--transition); + margin-bottom: 0.25rem; + font-size: 0.9rem; +} + +.source-item:hover { + background: var(--surface-hover); +} + +.source-item .source-title { + font-weight: 500; + color: var(--accent-glow); +} + +.source-item .source-url { + color: var(--text-muted); + font-size: 0.8rem; + display: block; + margin-top: 0.15rem; + word-break: break-all; +} + +/* โ”€โ”€ Error โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.error-box { + background: rgba(248, 113, 113, 0.1); + border: 1px solid var(--danger); + border-radius: var(--radius); + padding: 1rem 1.25rem; + color: var(--danger); +} + +/* โ”€โ”€ Footer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +footer { + text-align: center; + padding: 1.5rem 0; + color: var(--text-muted); + font-size: 0.8rem; + margin-top: auto; +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..c38aac3 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,47 @@ + + + + + + Sibilla โ€” L'Oracolo AI + + + +

+
+

๐Ÿ”ฎ Sibilla

+

L'oracolo AI che cerca nel web e risponde alle tue domande

+
+ +
+ + + +
+ +
+

Sibilla v0.1.0 โ€” Self-hosted AI Search

+
+
+ + + +