feat: add conversational RollCalc assistant
This commit is contained in:
@@ -3,13 +3,36 @@ Naue Roll Calculator - Beta
|
||||
Flask app with HTTP Basic Authentication
|
||||
"""
|
||||
|
||||
from flask import Flask, render_template, request, send_file, send_from_directory, jsonify
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
import json
|
||||
import os
|
||||
|
||||
from flask import (
|
||||
Flask,
|
||||
jsonify,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
send_from_directory,
|
||||
)
|
||||
from flask_httpauth import HTTPBasicAuth
|
||||
from werkzeug.security import check_password_hash
|
||||
from functools import wraps
|
||||
import os
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
from core_presets import CORE_PRESETS
|
||||
from conversation_service import (
|
||||
ConversationNotFoundError,
|
||||
ConversationReportNotFoundError,
|
||||
ConversationService,
|
||||
)
|
||||
from ollama_nlu import OllamaNLUClient
|
||||
from pdf_report import (
|
||||
ReportValidationError,
|
||||
report_from_calculation_result,
|
||||
render_roll_report,
|
||||
safe_report_filename,
|
||||
)
|
||||
from roll_calculation import calculate_roll, modify_calculation
|
||||
|
||||
app = Flask(__name__)
|
||||
auth = HTTPBasicAuth()
|
||||
@@ -74,12 +97,18 @@ def load_build_info():
|
||||
return build_info
|
||||
|
||||
BUILD_INFO = load_build_info()
|
||||
CONVERSATION_SERVICE = ConversationService(
|
||||
OllamaNLUClient(),
|
||||
build_info=BUILD_INFO,
|
||||
)
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_build_info():
|
||||
"""Make build metadata available in all templates."""
|
||||
return {"build_info": BUILD_INFO}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# AUTHENTICATION
|
||||
# ============================================================================
|
||||
@@ -96,6 +125,7 @@ def verify_password(username, password):
|
||||
return username
|
||||
return None
|
||||
|
||||
|
||||
def log_access(username, endpoint, method, status=200):
|
||||
"""Log all access attempts for audit trail"""
|
||||
log_entry = {
|
||||
@@ -125,7 +155,8 @@ def log_access(username, endpoint, method, status=200):
|
||||
def index():
|
||||
"""Main calculator page - requires authentication"""
|
||||
log_access(auth.current_user(), "/", "GET")
|
||||
return render_template("roll_calculator.html")
|
||||
return render_template("roll_calculator.html", core_presets=CORE_PRESETS)
|
||||
|
||||
|
||||
@app.route("/static/<path:filename>", methods=["GET"])
|
||||
@auth.login_required
|
||||
@@ -134,12 +165,14 @@ def serve_static(filename):
|
||||
log_access(auth.current_user(), f"/static/{filename}", "GET")
|
||||
return send_from_directory("static", filename)
|
||||
|
||||
|
||||
@app.route("/api/health", methods=["GET"])
|
||||
@auth.login_required
|
||||
def health_check():
|
||||
"""Simple health check endpoint"""
|
||||
return jsonify({"status": "ok", "version": "14.1"}), 200
|
||||
|
||||
|
||||
@app.route("/api/user", methods=["GET"])
|
||||
@auth.login_required
|
||||
def get_user():
|
||||
@@ -151,6 +184,156 @@ def get_user():
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}), 200
|
||||
|
||||
|
||||
@app.route("/api/calculations/roll", methods=["POST"])
|
||||
@auth.login_required
|
||||
def create_roll_calculation():
|
||||
"""Resolve and execute a deterministic direct roll-diameter request."""
|
||||
result = calculate_roll(request.get_json(silent=True), build_info=BUILD_INFO)
|
||||
log_access(auth.current_user(), "/api/calculations/roll", "POST")
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@app.route("/api/calculations/roll/modify", methods=["POST"])
|
||||
@auth.login_required
|
||||
def modify_roll_calculation():
|
||||
"""Apply explicit changes to structured calculation state and recalculate."""
|
||||
payload = request.get_json(silent=True)
|
||||
if not isinstance(payload, dict):
|
||||
return jsonify({"status": "invalid_parameter", "invalid": [
|
||||
{"field": "request", "message": "JSON object required"}
|
||||
]}), 400
|
||||
result = modify_calculation(
|
||||
payload.get("state"),
|
||||
payload.get("changes"),
|
||||
build_info=BUILD_INFO,
|
||||
)
|
||||
log_access(auth.current_user(), "/api/calculations/roll/modify", "POST")
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
def _create_pdf(calculation_request):
|
||||
result = calculate_roll(calculation_request, build_info=BUILD_INFO)
|
||||
report = report_from_calculation_result(result)
|
||||
generated_at = datetime.now().astimezone()
|
||||
pdf = render_roll_report(
|
||||
report,
|
||||
generated_at=generated_at,
|
||||
build_info=BUILD_INFO,
|
||||
)
|
||||
article_number = report["article"]["number"] if report["article"] else None
|
||||
filename = safe_report_filename(article_number, generated_at)
|
||||
return pdf, filename
|
||||
|
||||
|
||||
@app.route("/api/reports/roll-calculation.pdf", methods=["POST"])
|
||||
@auth.login_required
|
||||
def create_roll_calculation_report():
|
||||
"""Recalculate a request authoritatively and render its one-page PDF."""
|
||||
try:
|
||||
pdf, filename = _create_pdf(request.get_json(silent=True))
|
||||
except ReportValidationError as error:
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
log_access(
|
||||
auth.current_user(),
|
||||
"/api/reports/roll-calculation.pdf",
|
||||
"POST",
|
||||
)
|
||||
return send_file(
|
||||
BytesIO(pdf),
|
||||
mimetype="application/pdf",
|
||||
as_attachment=True,
|
||||
download_name=filename,
|
||||
max_age=0,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/conversations", methods=["POST"])
|
||||
@auth.login_required
|
||||
def create_conversation():
|
||||
"""Create one process-local conversational calculation session."""
|
||||
conversation_id = CONVERSATION_SERVICE.create_conversation()
|
||||
log_access(auth.current_user(), "/api/conversations", "POST", 201)
|
||||
return jsonify({
|
||||
"status": "created",
|
||||
"conversation_id": conversation_id,
|
||||
}), 201
|
||||
|
||||
|
||||
@app.route(
|
||||
"/api/conversations/<conversation_id>/messages",
|
||||
methods=["POST"],
|
||||
)
|
||||
@auth.login_required
|
||||
def create_conversation_message(conversation_id):
|
||||
"""Interpret one utterance and run the deterministic calculation workflow."""
|
||||
payload = request.get_json(silent=True)
|
||||
if not isinstance(payload, dict) or set(payload) != {"message"}:
|
||||
return jsonify({
|
||||
"status": "invalid_parameter",
|
||||
"message": "JSON object with exactly one message field required",
|
||||
}), 400
|
||||
if not isinstance(payload["message"], str) or not payload["message"].strip():
|
||||
return jsonify({
|
||||
"status": "invalid_parameter",
|
||||
"message": "message must be non-empty text",
|
||||
}), 400
|
||||
try:
|
||||
result = CONVERSATION_SERVICE.handle_message(
|
||||
conversation_id,
|
||||
payload["message"],
|
||||
)
|
||||
except ConversationNotFoundError:
|
||||
return jsonify({
|
||||
"status": "conversation_not_found",
|
||||
"message": "Conversation does not exist or has expired",
|
||||
}), 404
|
||||
|
||||
status_code = 200
|
||||
if result.get("status") == "nlu_error":
|
||||
status_code = 503 if result.get("error") in {
|
||||
"OllamaTimeoutError",
|
||||
"OllamaUnavailableError",
|
||||
} else 502
|
||||
log_access(
|
||||
auth.current_user(),
|
||||
f"/api/conversations/{conversation_id}/messages",
|
||||
"POST",
|
||||
status_code,
|
||||
)
|
||||
return jsonify(result), status_code
|
||||
|
||||
|
||||
@app.route(
|
||||
"/api/conversations/reports/<report_id>.pdf",
|
||||
methods=["GET"],
|
||||
)
|
||||
@auth.login_required
|
||||
def get_conversation_report(report_id):
|
||||
"""Recalculate a stored input-only report request and return its PDF."""
|
||||
try:
|
||||
calculation_request = CONVERSATION_SERVICE.report_request(report_id)
|
||||
pdf, filename = _create_pdf(calculation_request)
|
||||
except ConversationReportNotFoundError:
|
||||
return jsonify({"error": "Conversation report not found"}), 404
|
||||
except ReportValidationError as error:
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
log_access(
|
||||
auth.current_user(),
|
||||
f"/api/conversations/reports/{report_id}.pdf",
|
||||
"GET",
|
||||
)
|
||||
return send_file(
|
||||
BytesIO(pdf),
|
||||
mimetype="application/pdf",
|
||||
as_attachment=True,
|
||||
download_name=filename,
|
||||
max_age=0,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ERROR HANDLERS
|
||||
# ============================================================================
|
||||
@@ -160,16 +343,19 @@ def unauthorized(e):
|
||||
"""Handle 401 Unauthorized - browser will prompt for credentials"""
|
||||
return jsonify({"error": "Unauthorized - please provide valid credentials"}), 401
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
"""Handle 404 Not Found"""
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(e):
|
||||
"""Handle 500 Internal Server Error"""
|
||||
return jsonify({"error": "Internal server error"}), 500
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STARTUP
|
||||
# ============================================================================
|
||||
@@ -177,7 +363,7 @@ def internal_error(e):
|
||||
if __name__ == "__main__":
|
||||
os.makedirs("templates", exist_ok=True)
|
||||
os.makedirs("static", exist_ok=True)
|
||||
|
||||
|
||||
print("""
|
||||
╔═══════════════════════════════════════════════════════════╗
|
||||
║ Naue Roll Calculator - V14 + QoL Update ║
|
||||
@@ -188,7 +374,7 @@ if __name__ == "__main__":
|
||||
║ Starten auf: http://localhost:5000 ║
|
||||
╚═══════════════════════════════════════════════════════════╝
|
||||
""")
|
||||
|
||||
|
||||
app.run(
|
||||
host="0.0.0.0",
|
||||
port=5000,
|
||||
|
||||
Reference in New Issue
Block a user