Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,17 @@ class AuditLog(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
action = db.Column(db.String(100), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class CategorizationRule(db.Model):
__tablename__ = "categorization_rules"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
keyword = db.Column(db.String(200), nullable=False)
category_name = db.Column(db.String(100), nullable=False)
confidence = db.Column(db.Float, default=0.80, nullable=False)
source = db.Column(db.String(20), default="learned", nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
2 changes: 2 additions & 0 deletions packages/backend/app/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .categories import bp as categories_bp
from .docs import bp as docs_bp
from .dashboard import bp as dashboard_bp
from .categorize import bp as categorize_bp


def register_routes(app: Flask):
Expand All @@ -18,3 +19,4 @@ def register_routes(app: Flask):
app.register_blueprint(categories_bp, url_prefix="/categories")
app.register_blueprint(docs_bp, url_prefix="/docs")
app.register_blueprint(dashboard_bp, url_prefix="/dashboard")
app.register_blueprint(categorize_bp, url_prefix="/categorize")
112 changes: 112 additions & 0 deletions packages/backend/app/routes/categorize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import logging

from flask import Blueprint, jsonify, request
from flask_jwt_extended import jwt_required, get_jwt_identity

from ..services.categorization import (
categorize_transaction,
learn_from_correction,
batch_categorize,
)

bp = Blueprint("categorize", __name__)
logger = logging.getLogger("finmind.categorize")


@bp.post("")
@jwt_required()
def categorize():
"""Categorize a single transaction by description."""
uid = int(get_jwt_identity())
data = request.get_json() or {}
description = (data.get("description") or "").strip()
if not description:
return jsonify(error="description required"), 400
category_id = data.get("category_id")
result = categorize_transaction(
description=description,
existing_category_id=category_id,
user_id=uid,
)
logger.info("Categorized user=%s desc=%s result=%s", uid, description[:50], result.get("category"))
return jsonify(result)


@bp.post("/batch")
@jwt_required()
def categorize_batch():
"""Categorize multiple transactions at once."""
uid = int(get_jwt_identity())
data = request.get_json() or {}
transactions = data.get("transactions")
if not isinstance(transactions, list) or not transactions:
return jsonify(error="transactions list required"), 400
if len(transactions) > 100:
return jsonify(error="maximum 100 transactions per batch"), 400
results = batch_categorize(transactions, user_id=uid)
logger.info("Batch categorized user=%s count=%s", uid, len(results))
return jsonify(results=results, count=len(results))


@bp.post("/learn")
@jwt_required()
def learn():
"""Learn from a user's manual categorization correction."""
uid = int(get_jwt_identity())
data = request.get_json() or {}
description = (data.get("description") or "").strip()
category = (data.get("category") or "").strip()
if not description:
return jsonify(error="description required"), 400
if not category:
return jsonify(error="category required"), 400
result = learn_from_correction(
description=description,
correct_category=category,
user_id=uid,
)
logger.info("Learned user=%s cat=%s keywords=%s", uid, category, result.get("learned_count", 0))
return jsonify(result)


@bp.get("/rules")
@jwt_required()
def list_rules():
"""List learned categorization rules for the current user."""
uid = int(get_jwt_identity())
from ..models import CategorizationRule as RuleModel
from ..extensions import db

rules = (
db.session.query(RuleModel)
.filter_by(user_id=uid)
.order_by(RuleModel.confidence.desc())
.all()
)
return jsonify([
{
"id": r.id,
"keyword": r.keyword,
"category": r.category_name,
"confidence": round(r.confidence, 2),
"source": r.source,
}
for r in rules
])


@bp.delete("/rules/<int:rule_id>")
@jwt_required()
def delete_rule(rule_id: int):
"""Delete a learned categorization rule."""
uid = int(get_jwt_identity())
from ..models import CategorizationRule as RuleModel
from ..extensions import db

rule = db.session.get(RuleModel, rule_id)
if not rule or rule.user_id != uid:
return jsonify(error="not found"), 404
db.session.delete(rule)
db.session.commit()
logger.info("Deleted rule id=%s user=%s", rule_id, uid)
return jsonify(message="deleted")
7 changes: 6 additions & 1 deletion packages/backend/app/routes/expenses.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,8 +271,13 @@ def import_preview():
logger.exception("Import preview failed user=%s", uid)
return jsonify(error=f"failed to parse statement: {exc}"), 500
duplicates = sum(1 for t in transactions if _is_duplicate(uid, t))
validation = expense_import.validate_import_rows(transactions)
return jsonify(
total=len(transactions), duplicates=duplicates, transactions=transactions
total=len(transactions),
duplicates=duplicates,
transactions=transactions,
warnings=validation["warnings"],
summary=validation["summary"],
)


Expand Down
Loading