Spaces:
Running
Running
| 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 | |