techprotrade's picture
Deploy ATOM FastAPI command center runtime
c0cb280 verified
Raw
History Blame Contribute Delete
6.93 kB
from datetime import datetime
import json
import logging
from typing import Any, Dict, List, Optional
from accounting.models import Account, CategorizationProposal, CategorizationRule, Transaction
from sqlalchemy.orm import Session
from core.models import AuditLog
from integrations.ai_enhanced_service import (
AIModelType,
AIRequest,
AIServiceType,
AITaskType,
ai_enhanced_service,
)
logger = logging.getLogger(__name__)
class AICategorizer:
"""
Service for suggesting Chart of Accounts (CoA) categories for transactions.
"""
def __init__(self, db: Session):
self.db = db
async def propose_categorization(
self,
transaction: Transaction,
workspace_id: str,
confidence_threshold: float = 0.8
) -> Optional[CategorizationProposal]:
"""
Analyze transaction metadata and propose a CoA category.
"""
# 0. Check for existing rules (Learning Layer)
rule = self.db.query(CategorizationRule).filter(
CategorizationRule.workspace_id == workspace_id,
CategorizationRule.is_active == True,
Transaction.description.ilike("%" + CategorizationRule.merchant_pattern + "%")
).first()
if rule:
logger.info(f"Using existing rule for {transaction.description}: {rule.merchant_pattern}")
proposal = CategorizationProposal(
transaction_id=transaction.id,
suggested_account_id=rule.target_account_id,
confidence=0.95, # Rule match is high confidence
reasoning=f"Matched learned rule for '{rule.merchant_pattern}'"
)
self.db.add(proposal)
self.db.commit()
return proposal
# 1. Get available accounts for this workspace
accounts = self.db.query(Account).filter(Account.workspace_id == workspace_id).all()
coa_context = [
{"id": acc.id, "name": acc.name, "description": acc.description, "type": acc.type.value}
for acc in accounts
]
# 2. Prepare AI Request
prompt_data = {
"transaction": {
"description": transaction.description,
"amount": sum(je.amount for je in transaction.journal_entries if je.type == "debit"), # Simplified total
"date": transaction.transaction_date.isoformat(),
"metadata": transaction.metadata_json
},
"chart_of_accounts": coa_context
}
ai_request = AIRequest(
request_id=f"categorize_{transaction.id}",
task_type=AITaskType.NATURAL_LANGUAGE_COMMANDS,
model_type=AIModelType.GPT_4,
service_type=AIServiceType.OPENAI,
input_data={
"text": json.dumps(prompt_data),
"instruction": (
"Based on the transaction description and metadata, pick the most appropriate "
"account from the provided Chart of Accounts. Return JSON with 'account_id', "
"'confidence' (0-1), and 'reasoning'."
)
},
platform="accounting"
)
try:
ai_response = await ai_enhanced_service.process_ai_request(ai_request)
if ai_response.confidence <= 0:
logger.error(f"AI Categorization failed or had 0 confidence")
return None
# 3. Parse AI output (assuming it returns a dict in output_data)
# In a real scenario, we might need to parse JSON from a string if the AI returns text.
result = ai_response.output_data
if isinstance(result, str):
try:
result = json.loads(result)
except (json.JSONDecodeError, ValueError, TypeError):
logger.error("Failed to parse AI response as JSON")
return None
suggested_account_id = result.get("account_id")
confidence = result.get("confidence", 0.0)
reasoning = result.get("reasoning", "")
if not suggested_account_id:
return None
# 4. Save Proposal
proposal = CategorizationProposal(
transaction_id=transaction.id,
suggested_account_id=suggested_account_id,
confidence=confidence,
reasoning=reasoning
)
self.db.add(proposal)
self.db.commit()
logger.info(f"Created categorization proposal for {transaction.id} with confidence {confidence}")
return proposal
except Exception as e:
logger.error(f"Error in AICategorizer: {e}")
return None
def accept_proposal(self, proposal_id: str, user_id: str) -> bool:
"""User manual approval of a categorization proposal"""
proposal = self.db.query(CategorizationProposal).filter(CategorizationProposal.id == proposal_id).first()
if not proposal:
return False
proposal.is_accepted = True
proposal.reviewed_by = user_id
proposal.reviewed_at = datetime.utcnow()
# LEARNING LAYER: Create or update a rule
# Extract a simplified merchant name from description
merchant = proposal.transaction.description.split()[0] # Very simple heuristic
existing_rule = self.db.query(CategorizationRule).filter(
CategorizationRule.workspace_id == proposal.transaction.workspace_id,
CategorizationRule.merchant_pattern == merchant
).first()
if existing_rule:
if existing_rule.target_account_id == proposal.suggested_account_id:
existing_rule.confidence_weight += 0.1 # Reinforce
else:
# Disagreement - lower confidence or update if weight is low
existing_rule.confidence_weight -= 0.2
else:
new_rule = CategorizationRule(
workspace_id=proposal.transaction.workspace_id,
merchant_pattern=merchant,
target_account_id=proposal.suggested_account_id,
confidence_weight=1.1
)
self.db.add(new_rule)
# AUDIT TRAIL: Record the approval
audit = AuditLog(
event_type="FINANCIAL_APPROVAL",
security_level="medium",
threat_level="none",
user_id=user_id,
workspace_id=proposal.transaction.workspace_id,
resource=f"Transaction:{proposal.transaction_id}",
action="ACCEPT_CATEGORIZATION",
description=f"User approved categorization rule for '{merchant}' to account '{proposal.suggested_account_id}'",
success=True
)
self.db.add(audit)
self.db.commit()
return True