| 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. |
| """ |
| |
| 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, |
| reasoning=f"Matched learned rule for '{rule.merchant_pattern}'" |
| ) |
| self.db.add(proposal) |
| self.db.commit() |
| return proposal |
|
|
| |
| 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 |
| ] |
|
|
| |
| prompt_data = { |
| "transaction": { |
| "description": transaction.description, |
| "amount": sum(je.amount for je in transaction.journal_entries if je.type == "debit"), |
| "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 |
|
|
| |
| |
| 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 |
|
|
| |
| 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() |
| |
| |
| |
| merchant = proposal.transaction.description.split()[0] |
| |
| 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 |
| else: |
| |
| 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 = 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 |
|
|