From ef2b0e1f8334f1ebae5d782b7541ccc8d35fffbb Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Sat, 22 Aug 2026 21:36:27 +0530 Subject: [PATCH] Add Phase 12 server AI semantic accounting layer --- ...20260822_accounting_ai_semantic_phase12.py | 70 +++ app/modules/accounting/ai_models.py | 70 +++ app/modules/accounting/ai_provider.py | 154 ++++++ app/modules/accounting/ai_service.py | 460 ++++++++++++++++++ app/modules/accounting/ai_ui.py | 55 +++ app/modules/accounting/bank_service.py | 18 + app/modules/accounting/bank_ui.py | 36 +- .../accounting/purchase_review_service.py | 35 +- app/modules/accounting/purchase_review_ui.py | 51 +- .../templates/accounting/ai_dashboard.html | 61 +++ .../templates/accounting/bank_posting.html | 6 + .../templates/accounting/purchase_review.html | 9 +- .../templates/accounting/tally.html | 1 + app/ui/app.py | 2 + 14 files changed, 1023 insertions(+), 5 deletions(-) create mode 100644 alembic/versions/20260822_accounting_ai_semantic_phase12.py create mode 100644 app/modules/accounting/ai_models.py create mode 100644 app/modules/accounting/ai_provider.py create mode 100644 app/modules/accounting/ai_service.py create mode 100644 app/modules/accounting/ai_ui.py create mode 100644 app/modules/accounting/templates/accounting/ai_dashboard.html diff --git a/alembic/versions/20260822_accounting_ai_semantic_phase12.py b/alembic/versions/20260822_accounting_ai_semantic_phase12.py new file mode 100644 index 0000000..852cb99 --- /dev/null +++ b/alembic/versions/20260822_accounting_ai_semantic_phase12.py @@ -0,0 +1,70 @@ +"""Phase 12 server AI semantic accounting layer. + +Revision ID: 20260822_accounting_ai_p12 +Revises: 20260822_bank_client_archive_p11b +""" +from alembic import op +import sqlalchemy as sa + + +revision = "20260822_accounting_ai_p12" +down_revision = "20260822_bank_client_archive_p11b" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "accounting_ai_semantic_decisions", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False), + sa.Column("source_type", sa.String(30), nullable=False), + sa.Column("source_record_id", sa.Integer(), nullable=False), + sa.Column("source_fingerprint", sa.String(80), nullable=False, server_default=""), + sa.Column("provider_name", sa.String(60), nullable=False, server_default=""), + sa.Column("model_name", sa.String(120), nullable=False, server_default=""), + sa.Column("prompt_version", sa.String(40), nullable=False, server_default="phase12-v1"), + sa.Column("deterministic_nature_id", sa.Integer(), sa.ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True), + sa.Column("deterministic_confidence", sa.Integer(), nullable=False, server_default="0"), + sa.Column("ai_nature_id", sa.Integer(), sa.ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True), + sa.Column("ai_nature_code", sa.String(80), nullable=False, server_default=""), + sa.Column("ai_confidence", sa.Integer(), nullable=False, server_default="0"), + sa.Column("combined_confidence", sa.Integer(), nullable=False, server_default="0"), + sa.Column("capital_revenue", sa.String(20), nullable=False, server_default=""), + sa.Column("business_personal", sa.String(20), nullable=False, server_default="business"), + sa.Column("concise_reason", sa.Text(), nullable=False, server_default=""), + sa.Column("alternatives_json", sa.Text(), nullable=True), + sa.Column("review_flags_json", sa.Text(), nullable=True), + sa.Column("context_json", sa.Text(), nullable=True), + sa.Column("raw_response_json", sa.Text(), nullable=True), + sa.Column("applied_to_source", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("skipped_reason", sa.Text(), nullable=True), + sa.Column("status", sa.String(30), nullable=False, server_default="completed"), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("final_nature_id", sa.Integer(), sa.ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True), + sa.Column("final_ledger_name", sa.String(255), nullable=False, server_default=""), + sa.Column("suggestion_accepted", sa.Boolean(), nullable=True), + sa.Column("reviewed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("reviewed_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("input_tokens", sa.Integer(), nullable=False, server_default="0"), + sa.Column("output_tokens", sa.Integer(), nullable=False, server_default="0"), + sa.Column("latency_ms", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + + for name in ( + "tenant_id", "client_id", "source_type", "source_record_id", "source_fingerprint", + "deterministic_nature_id", "ai_nature_id", "applied_to_source", "status", + "final_nature_id", "suggestion_accepted", "created_at_utc", + ): + op.create_index( + f"ix_accounting_ai_semantic_decisions_{name}", + "accounting_ai_semantic_decisions", + [name], + ) + + +def downgrade(): + op.drop_table("accounting_ai_semantic_decisions") diff --git a/app/modules/accounting/ai_models.py b/app/modules/accounting/ai_models.py new file mode 100644 index 0000000..e469cab --- /dev/null +++ b/app/modules/accounting/ai_models.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db.common import CommonBase + + +class AccountingAISemanticDecision(CommonBase): + __tablename__ = "accounting_ai_semantic_decisions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + + source_type: Mapped[str] = mapped_column(String(30), nullable=False, index=True) + source_record_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + source_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False, default="", index=True) + + provider_name: Mapped[str] = mapped_column(String(60), nullable=False, default="") + model_name: Mapped[str] = mapped_column(String(120), nullable=False, default="") + prompt_version: Mapped[str] = mapped_column(String(40), nullable=False, default="phase12-v1") + + deterministic_nature_id: Mapped[int | None] = mapped_column( + ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True, index=True + ) + deterministic_confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + ai_nature_id: Mapped[int | None] = mapped_column( + ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True, index=True + ) + ai_nature_code: Mapped[str] = mapped_column(String(80), nullable=False, default="") + ai_confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + combined_confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + capital_revenue: Mapped[str] = mapped_column(String(20), nullable=False, default="") + business_personal: Mapped[str] = mapped_column(String(20), nullable=False, default="business") + concise_reason: Mapped[str] = mapped_column(Text, nullable=False, default="") + alternatives_json: Mapped[str | None] = mapped_column(Text, nullable=True) + review_flags_json: Mapped[str | None] = mapped_column(Text, nullable=True) + context_json: Mapped[str | None] = mapped_column(Text, nullable=True) + raw_response_json: Mapped[str | None] = mapped_column(Text, nullable=True) + + applied_to_source: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + skipped_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="completed", index=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + + final_nature_id: Mapped[int | None] = mapped_column( + ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True, index=True + ) + final_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + suggestion_accepted: Mapped[bool | None] = mapped_column(Boolean, nullable=True, index=True) + reviewed_by_user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + input_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + output_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + latency_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + created_by_user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + created_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc), index=True + ) diff --git a/app/modules/accounting/ai_provider.py b/app/modules/accounting/ai_provider.py new file mode 100644 index 0000000..7c60be4 --- /dev/null +++ b/app/modules/accounting/ai_provider.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import json +import os +import time +import urllib.error +import urllib.request +from dataclasses import dataclass +from typing import Any + + +class AccountingAIProviderError(RuntimeError): + pass + + +@dataclass +class ProviderResult: + provider: str + model: str + data: dict[str, Any] + raw: dict[str, Any] + input_tokens: int = 0 + output_tokens: int = 0 + latency_ms: int = 0 + + +class AccountingSemanticProvider: + provider_name = "base" + + def is_configured(self) -> bool: + raise NotImplementedError + + def model_name(self) -> str: + raise NotImplementedError + + def classify(self, *, system_text: str, user_payload: dict[str, Any], schema: dict[str, Any]) -> ProviderResult: + raise NotImplementedError + + +class OpenAIResponsesProvider(AccountingSemanticProvider): + provider_name = "openai" + + def __init__(self): + self.api_key = (os.getenv("OPENAI_API_KEY") or "").strip() + self.model = (os.getenv("ACCOUNTING_AI_MODEL") or "gpt-5-mini").strip() + self.base_url = (os.getenv("OPENAI_BASE_URL") or "https://api.openai.com/v1").rstrip("/") + self.timeout = max(10, min(120, int(os.getenv("ACCOUNTING_AI_TIMEOUT_SECONDS", "45") or 45))) + + def is_configured(self) -> bool: + return bool(self.api_key and self.model) + + def model_name(self) -> str: + return self.model + + @staticmethod + def _extract_output_text(response: dict[str, Any]) -> str: + value = response.get("output_text") + if isinstance(value, str) and value.strip(): + return value.strip() + + for item in response.get("output") or []: + if not isinstance(item, dict) or item.get("type") != "message": + continue + for content in item.get("content") or []: + if not isinstance(content, dict): + continue + if content.get("type") in {"output_text", "text"}: + text = content.get("text") + if isinstance(text, str) and text.strip(): + return text.strip() + raise AccountingAIProviderError("AI response did not contain structured output text.") + + def classify(self, *, system_text: str, user_payload: dict[str, Any], schema: dict[str, Any]) -> ProviderResult: + if not self.is_configured(): + raise AccountingAIProviderError( + "Server AI is not configured. Set OPENAI_API_KEY and ACCOUNTING_AI_MODEL on the ERP server." + ) + + body = { + "model": self.model, + "store": False, + "input": [ + { + "role": "system", + "content": [{"type": "input_text", "text": system_text}], + }, + { + "role": "user", + "content": [{"type": "input_text", "text": json.dumps(user_payload, ensure_ascii=False)}], + }, + ], + "text": { + "format": { + "type": "json_schema", + "name": "accounting_semantic_classification", + "strict": True, + "schema": schema, + } + }, + } + + request = urllib.request.Request( + self.base_url + "/responses", + data=json.dumps(body, ensure_ascii=False).encode("utf-8"), + method="POST", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "User-Agent": "ARRR-ERP-Accounting-AI/12", + }, + ) + + started = time.monotonic() + last_error = None + for attempt in range(2): + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + raw = json.loads(response.read().decode("utf-8")) + text = self._extract_output_text(raw) + data = json.loads(text) + usage = raw.get("usage") or {} + return ProviderResult( + provider=self.provider_name, + model=str(raw.get("model") or self.model), + data=data, + raw=raw, + input_tokens=int(usage.get("input_tokens") or 0), + output_tokens=int(usage.get("output_tokens") or 0), + latency_ms=int((time.monotonic() - started) * 1000), + ) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="ignore")[:2000] + last_error = AccountingAIProviderError( + f"AI provider HTTP {exc.code}: {detail or exc.reason}" + ) + if exc.code not in {429, 500, 502, 503, 504} or attempt: + raise last_error + time.sleep(1.0) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + last_error = AccountingAIProviderError(f"AI provider request failed: {exc}") + if attempt: + raise last_error + time.sleep(1.0) + + raise last_error or AccountingAIProviderError("AI provider request failed.") + + +def get_accounting_semantic_provider() -> AccountingSemanticProvider: + provider = (os.getenv("ACCOUNTING_AI_PROVIDER") or "openai").strip().lower() + if provider == "openai": + return OpenAIResponsesProvider() + raise AccountingAIProviderError( + f"Unsupported ACCOUNTING_AI_PROVIDER '{provider}'. Current Phase 12 provider: openai." + ) diff --git a/app/modules/accounting/ai_service.py b/app/modules/accounting/ai_service.py new file mode 100644 index 0000000..1273b12 --- /dev/null +++ b/app/modules/accounting/ai_service.py @@ -0,0 +1,460 @@ +from __future__ import annotations + +import hashlib +import json +import os +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import func, select + +from app.modules.accounting.ai_models import AccountingAISemanticDecision +from app.modules.accounting.ai_provider import AccountingAIProviderError, get_accounting_semantic_provider +from app.modules.accounting.gstr2b_models import AccountingGSTR2BPurchase +from app.modules.accounting.taxonomy_models import AccountingNature +from app.modules.clients.models import Client, ClientBusinessProfile + + +PROMPT_VERSION = "phase12-v1" +DEFAULT_AI_GATE = 90 + + +def _utcnow(): + return datetime.now(timezone.utc) + + +def _s(value) -> str: + return str(value or "").strip() + + +def _i(value, default=0) -> int: + try: + return int(round(float(value))) + except Exception: + return int(default) + + +def ai_gate() -> int: + return max(50, min(99, _i(os.getenv("ACCOUNTING_AI_GATE_CONFIDENCE", DEFAULT_AI_GATE), DEFAULT_AI_GATE))) + + +def provider_status() -> dict[str, Any]: + try: + provider = get_accounting_semantic_provider() + return { + "provider": provider.provider_name, + "model": provider.model_name(), + "configured": bool(provider.is_configured()), + "gate": ai_gate(), + } + except Exception as exc: + return {"provider": "", "model": "", "configured": False, "gate": ai_gate(), "error": str(exc)} + + +def _profile_context(db, client_id: int) -> dict[str, Any]: + client = db.get(Client, int(client_id)) + profile = db.execute( + select(ClientBusinessProfile).where(ClientBusinessProfile.client_id == int(client_id)) + ).scalar_one_or_none() + + data = { + "client_name": _s(getattr(client, "client_name", "")), + "trade_name": _s(getattr(client, "trade_name", "")), + "primary_industry": "", + "primary_business_activity": "", + "secondary_business_activities": "", + "business_model": "", + "main_products": "", + "main_services": "", + "inventory_maintained": None, + "project_job_based": None, + "capital_intensive": None, + "vehicle_intensive": None, + "profile_status": "", + "profile_confidence": 0, + } + if profile: + for key in ( + "primary_industry", "primary_business_activity", "secondary_business_activities", + "business_model", "main_products", "main_services", "inventory_maintained", + "project_job_based", "capital_intensive", "vehicle_intensive", "profile_status", + ): + data[key] = getattr(profile, key, None) + data["profile_confidence"] = int(getattr(profile, "confidence_score", 0) or 0) + return data + + +def _taxonomy(db, tenant_id: int) -> list[AccountingNature]: + return list(db.execute( + select(AccountingNature).where( + AccountingNature.tenant_id == int(tenant_id), + AccountingNature.is_active.is_(True), + AccountingNature.is_posting_nature.is_(True), + ).order_by(AccountingNature.sort_order, AccountingNature.name) + ).scalars().all()) + + +def _taxonomy_payload(rows: list[AccountingNature]) -> list[dict[str, Any]]: + return [{ + "code": row.code, + "name": row.name, + "group": row.classification_group, + "capital_revenue": row.capital_revenue, + "description": _s(row.description)[:500], + } for row in rows] + + +def _schema(codes: list[str]) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "nature_code": {"type": "string", "enum": codes}, + "confidence": {"type": "integer", "minimum": 0, "maximum": 100}, + "capital_revenue": {"type": "string", "enum": ["capital", "revenue", "uncertain"]}, + "business_personal": {"type": "string", "enum": ["business", "personal", "uncertain"]}, + "reason": {"type": "string", "maxLength": 500}, + "alternatives": { + "type": "array", + "maxItems": 3, + "items": { + "type": "object", + "properties": { + "nature_code": {"type": "string", "enum": codes}, + "confidence": {"type": "integer", "minimum": 0, "maximum": 100}, + }, + "required": ["nature_code", "confidence"], + "additionalProperties": False, + }, + }, + "review_flags": { + "type": "array", + "maxItems": 5, + "items": {"type": "string", "maxLength": 120}, + }, + }, + "required": [ + "nature_code", "confidence", "capital_revenue", "business_personal", + "reason", "alternatives", "review_flags", + ], + "additionalProperties": False, + } + + +SYSTEM_TEXT = """You classify accounting transactions for an Indian accounting/audit ERP. +You are a semantic fallback layer, not the primary rules engine. +Choose exactly one accounting nature from ALLOWED_TAXONOMY. Never invent a nature or ledger. +Use the client business profile, supplier/party information, HSN/description, source type, +amount and deterministic evidence. Distinguish revenue expense from capital acquisition +conservatively. Flag uncertainty, possible personal expenditure, mixed-purpose transactions, +or insufficient evidence. Return only the required structured object. Do not create ledgers, +do not decide GST eligibility, and do not authorize posting to Tally.""" + + +def _combined_confidence(*, ai_conf: int, deterministic_conf: int, profile_conf: int, agreement: bool) -> int: + ai_conf = max(0, min(100, int(ai_conf))) + deterministic_conf = max(0, min(100, int(deterministic_conf))) + profile_conf = max(0, min(100, int(profile_conf))) + value = (0.70 * ai_conf) + (0.20 * profile_conf) + (0.10 * deterministic_conf) + if agreement and deterministic_conf > 0: + value += 5 + return max(0, min(99, int(round(value)))) + + +def _source_fingerprint(source_type: str, source_record_id: int, payload: dict[str, Any]) -> str: + raw = f"{source_type}|{source_record_id}|{json.dumps(payload, sort_keys=True, ensure_ascii=False)}" + return hashlib.sha256(raw.encode("utf-8", "ignore")).hexdigest() + + +def latest_decision(db, *, tenant_id: int, source_type: str, source_record_id: int): + return db.execute( + select(AccountingAISemanticDecision).where( + AccountingAISemanticDecision.tenant_id == int(tenant_id), + AccountingAISemanticDecision.source_type == source_type, + AccountingAISemanticDecision.source_record_id == int(source_record_id), + ).order_by(AccountingAISemanticDecision.id.desc()).limit(1) + ).scalar_one_or_none() + + +def _run( + db, *, + tenant_id: int, + client_id: int, + source_type: str, + source_record_id: int, + deterministic_nature_id: int | None, + deterministic_confidence: int, + transaction: dict[str, Any], + created_by_user_id: int, +): + gate = ai_gate() + if int(deterministic_confidence or 0) >= gate: + raise ValueError( + f"AI is not required because deterministic confidence is already " + f"{int(deterministic_confidence or 0)}% (AI gate {gate}%)." + ) + + natures = _taxonomy(db, tenant_id) + if not natures: + raise ValueError("Accounting taxonomy is empty. Configure Phase 4 taxonomy first.") + + profile = _profile_context(db, client_id) + nature_by_code = {row.code: row for row in natures} + nature_by_id = {row.id: row for row in natures} + + deterministic = nature_by_id.get(int(deterministic_nature_id)) if deterministic_nature_id else None + payload = { + "source_type": source_type, + "client_business_profile": profile, + "transaction": transaction, + "deterministic_evidence": { + "nature_code": deterministic.code if deterministic else "", + "nature_name": deterministic.name if deterministic else "", + "confidence": int(deterministic_confidence or 0), + }, + "allowed_taxonomy": _taxonomy_payload(natures), + } + + provider = get_accounting_semantic_provider() + decision = AccountingAISemanticDecision( + tenant_id=tenant_id, + client_id=client_id, + source_type=source_type, + source_record_id=source_record_id, + source_fingerprint=_source_fingerprint(source_type, source_record_id, payload), + provider_name=provider.provider_name, + model_name=provider.model_name(), + prompt_version=PROMPT_VERSION, + deterministic_nature_id=deterministic_nature_id, + deterministic_confidence=int(deterministic_confidence or 0), + context_json=json.dumps(payload, ensure_ascii=False), + status="running", + created_by_user_id=created_by_user_id, + ) + db.add(decision) + db.commit() + db.refresh(decision) + + try: + result = provider.classify( + system_text=SYSTEM_TEXT, + user_payload=payload, + schema=_schema(list(nature_by_code)), + ) + data = result.data + nature = nature_by_code.get(_s(data.get("nature_code"))) + if not nature: + raise AccountingAIProviderError("AI returned a nature outside the active ERP taxonomy.") + + ai_conf = max(0, min(100, _i(data.get("confidence")))) + agreement = bool(deterministic and deterministic.id == nature.id) + combined = _combined_confidence( + ai_conf=ai_conf, + deterministic_conf=int(deterministic_confidence or 0), + profile_conf=int(profile.get("profile_confidence") or 0), + agreement=agreement, + ) + + decision.provider_name = result.provider + decision.model_name = result.model + decision.ai_nature_id = nature.id + decision.ai_nature_code = nature.code + decision.ai_confidence = ai_conf + decision.combined_confidence = combined + decision.capital_revenue = _s(data.get("capital_revenue")) + decision.business_personal = _s(data.get("business_personal")) or "business" + decision.concise_reason = _s(data.get("reason"))[:1000] + decision.alternatives_json = json.dumps(data.get("alternatives") or [], ensure_ascii=False) + decision.review_flags_json = json.dumps(data.get("review_flags") or [], ensure_ascii=False) + decision.raw_response_json = json.dumps(result.raw, ensure_ascii=False)[:20000] + decision.input_tokens = int(result.input_tokens or 0) + decision.output_tokens = int(result.output_tokens or 0) + decision.latency_ms = int(result.latency_ms or 0) + decision.status = "completed" + db.add(decision) + db.commit() + db.refresh(decision) + return decision, nature + except Exception as exc: + decision.status = "failed" + decision.error_message = str(exc)[:4000] + db.add(decision) + db.commit() + raise + + +def ai_assist_purchase(db, *, row: AccountingGSTR2BPurchase, user_id: int): + if row.review_status == "reviewed": + raise ValueError("This purchase is already reviewed.") + transaction = { + "supplier_name": row.supplier_name, + "supplier_gstin": row.supplier_gstin, + "invoice_number": row.invoice_number, + "invoice_date": row.invoice_date, + "document_type": row.document_type, + "invoice_type": row.invoice_type, + "hsn_code": row.hsn_code, + "description": row.description_text or "", + "taxable_value": row.taxable_value, + "invoice_value": row.invoice_value, + "place_of_supply": row.place_of_supply, + "reverse_charge": row.reverse_charge, + "itc_availability": row.itc_availability, + } + decision, nature = _run( + db, + tenant_id=row.tenant_id, + client_id=row.client_id, + source_type="gstr2b", + source_record_id=row.id, + deterministic_nature_id=row.suggested_nature_id, + deterministic_confidence=row.suggested_confidence, + transaction=transaction, + created_by_user_id=user_id, + ) + + # AI may strengthen/change the accounting nature, but it never invents a ledger. + # Preserve any existing mapped ledger only when the nature did not change. + if row.suggested_nature_id != nature.id: + row.suggested_ledger_name = "" + row.suggested_nature_id = nature.id + row.suggested_confidence = decision.combined_confidence + existing = [] + try: + existing = json.loads(row.suggestion_explanation_json or "[]") + except Exception: + pass + existing.append( + f"AI semantic fallback ({decision.model_name}): {decision.concise_reason} " + f"Combined confidence {decision.combined_confidence}%." + ) + row.suggestion_explanation_json = json.dumps(existing[-8:], ensure_ascii=False) + row.review_status = "suggested" if decision.combined_confidence >= 60 else "review_required" + decision.applied_to_source = True + db.add(row) + db.add(decision) + db.commit() + return decision + + +def ai_assist_bank(db, *, tx, user_id: int): + if tx.review_status == "reviewed": + raise ValueError("This bank transaction is already reviewed.") + if _s(getattr(tx, "contra_pair_id", "")): + raise ValueError("Matched inter-bank contra does not require AI classification.") + + transaction = { + "bank_name": tx.bank_name, + "account_number": tx.account_number, + "transaction_date": tx.transaction_date, + "direction": tx.direction, + "debit": tx.debit, + "credit": tx.credit, + "amount": tx.amount, + "narration": tx.narration, + "reference_no": tx.reference_no, + "transfer_reference": tx.transfer_reference, + "detected_party": tx.auto_party, + "analyzer_category": tx.analyzer_category, + "analyzer_nature": tx.analyzer_nature, + } + decision, nature = _run( + db, + tenant_id=tx.tenant_id, + client_id=tx.client_id, + source_type="bank", + source_record_id=tx.id, + deterministic_nature_id=tx.suggested_nature_id, + deterministic_confidence=tx.suggested_confidence, + transaction=transaction, + created_by_user_id=user_id, + ) + + if tx.suggested_nature_id != nature.id: + tx.suggested_ledger_name = "" + tx.suggested_nature_id = nature.id + tx.suggested_confidence = decision.combined_confidence + reasons = [] + try: + reasons = json.loads(tx.suggestion_reason_json or "[]") + except Exception: + pass + reasons.append( + f"AI semantic fallback ({decision.model_name}): {decision.concise_reason} " + f"Combined confidence {decision.combined_confidence}%." + ) + tx.suggestion_reason_json = json.dumps(reasons[-8:], ensure_ascii=False) + decision.applied_to_source = True + db.add(tx) + db.add(decision) + db.commit() + return decision + + +def mark_review_outcome( + db, *, + tenant_id: int, + source_type: str, + source_record_id: int, + final_nature_id: int | None, + final_ledger_name: str, + user_id: int, +): + decision = latest_decision( + db, + tenant_id=tenant_id, + source_type=source_type, + source_record_id=source_record_id, + ) + if not decision or decision.status != "completed": + return None + decision.final_nature_id = final_nature_id + decision.final_ledger_name = _s(final_ledger_name) + decision.suggestion_accepted = bool( + final_nature_id and decision.ai_nature_id and int(final_nature_id) == int(decision.ai_nature_id) + ) + decision.reviewed_by_user_id = user_id + decision.reviewed_at_utc = _utcnow() + db.add(decision) + db.commit() + return decision + + +def usage_summary(db, *, tenant_id: int, client_id: int | None = None): + where = [AccountingAISemanticDecision.tenant_id == int(tenant_id)] + if client_id: + where.append(AccountingAISemanticDecision.client_id == int(client_id)) + + total = int(db.scalar(select(func.count(AccountingAISemanticDecision.id)).where(*where)) or 0) + completed = int(db.scalar(select(func.count(AccountingAISemanticDecision.id)).where( + *where, AccountingAISemanticDecision.status == "completed" + )) or 0) + accepted = int(db.scalar(select(func.count(AccountingAISemanticDecision.id)).where( + *where, AccountingAISemanticDecision.suggestion_accepted.is_(True) + )) or 0) + corrected = int(db.scalar(select(func.count(AccountingAISemanticDecision.id)).where( + *where, AccountingAISemanticDecision.suggestion_accepted.is_(False) + )) or 0) + input_tokens = int(db.scalar(select(func.coalesce(func.sum(AccountingAISemanticDecision.input_tokens), 0)).where(*where)) or 0) + output_tokens = int(db.scalar(select(func.coalesce(func.sum(AccountingAISemanticDecision.output_tokens), 0)).where(*where)) or 0) + + return { + "total": total, + "completed": completed, + "accepted": accepted, + "corrected": corrected, + "reviewed": accepted + corrected, + "acceptance_rate": round((accepted * 100 / (accepted + corrected)), 1) if (accepted + corrected) else 0, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + } + + +def recent_decisions(db, *, tenant_id: int, client_id: int | None = None, limit: int = 100): + stmt = select(AccountingAISemanticDecision).where( + AccountingAISemanticDecision.tenant_id == int(tenant_id) + ) + if client_id: + stmt = stmt.where(AccountingAISemanticDecision.client_id == int(client_id)) + return list(db.execute( + stmt.order_by(AccountingAISemanticDecision.id.desc()).limit(max(1, min(500, int(limit)))) + ).scalars().all()) diff --git a/app/modules/accounting/ai_ui.py b/app/modules/accounting/ai_ui.py new file mode 100644 index 0000000..5476418 --- /dev/null +++ b/app/modules/accounting/ai_ui.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from fastapi import APIRouter, Request + +from app.core.db.common import CommonSessionLocal +from app.core.security.csrf import get_or_create_csrf_token +from app.core.templating import templates +from app.modules.accounting.ai_service import provider_status, recent_decisions, usage_summary +from app.modules.accounting.ui import _require_partner, _visible_clients +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles + +router = APIRouter(prefix="/tools/accounting/ai", tags=["accounting-ai-ui"]) + + +@router.get("") +def page(request: Request, client_id: int | None = None): + db = CommonSessionLocal() + try: + user, denied = _require_partner(request, db, "accounting.learning.view") + if denied: + return denied + clients, scope = _visible_clients(db, request, user) + selected = next( + (client for client in clients if client_id and int(client.id) == int(client_id)), + None, + ) + summary = usage_summary( + db, + tenant_id=scope.tenant_id, + client_id=selected.id if selected else None, + ) + rows = recent_decisions( + db, + tenant_id=scope.tenant_id, + client_id=selected.id if selected else None, + limit=100, + ) + return templates.TemplateResponse( + "modules/accounting/templates/accounting/ai_dashboard.html", + { + "request": request, + "current_user": user, + "current_user_roles": get_user_roles(db, user.id), + "current_user_permissions": get_user_permissions(db, user.id), + "csrf_token": get_or_create_csrf_token(request), + "title": "Accounting AI", + "clients": clients, + "selected_client": selected, + "provider_status": provider_status(), + "summary": summary, + "decisions": rows, + }, + ) + finally: + db.close() diff --git a/app/modules/accounting/bank_service.py b/app/modules/accounting/bank_service.py index 905f727..8edebbe 100644 --- a/app/modules/accounting/bank_service.py +++ b/app/modules/accounting/bank_service.py @@ -9,6 +9,7 @@ import pandas as pd from sqlalchemy import func, select from app.modules.accounting.bank_models import AccountingBankTransaction +from app.modules.accounting.ai_service import ai_assist_bank as run_ai_assist_bank, mark_review_outcome from app.modules.accounting.ledger_learning_service import ( active_natures, available_tally_guids, @@ -239,9 +240,26 @@ def confirm_review(db, *, tx_id, tenant_id, client_id, nature_id, ledger_name, v db.add(tx) db.commit() + mark_review_outcome( + db, + tenant_id=tenant_id, + source_type="bank", + source_record_id=tx.id, + final_nature_id=tx.final_nature_id, + final_ledger_name=tx.final_ledger_name, + user_id=user_id, + ) return tx +def ai_assist_transaction(db, *, tx_id, tenant_id, client_id, user_id): + tx = db.get(AccountingBankTransaction, int(tx_id)) + if not tx or int(tx.tenant_id) != int(tenant_id) or int(tx.client_id) != int(client_id): + raise ValueError("Bank transaction was not found.") + return run_ai_assist_bank(db, tx=tx, user_id=user_id) + + + def visible_workstations(db, tenant_id, branch_id=None): stmt = select(ERPWorkstationAgent).where( ERPWorkstationAgent.tenant_id == tenant_id, diff --git a/app/modules/accounting/bank_ui.py b/app/modules/accounting/bank_ui.py index bee99d2..c7fe337 100644 --- a/app/modules/accounting/bank_ui.py +++ b/app/modules/accounting/bank_ui.py @@ -9,7 +9,7 @@ from app.core.db.common import CommonSessionLocal from app.core.security.csrf import get_or_create_csrf_token, validate_csrf from app.core.templating import templates from app.modules.accounting.bank_service import ( - confirm_review, import_completed_job, preflight_choices, queue_post, queue_preflight, + ai_assist_transaction, confirm_review, import_completed_job, preflight_choices, queue_post, queue_preflight, queue_rows, sync_posting, visible_workstations, ) from app.modules.accounting.ledger_learning_service import active_natures @@ -121,3 +121,37 @@ def post(request: Request, tx_id: int, client_id: int = Form(...), bank_ledger_n except Exception as exc: db.rollback(); return _go(client_id, error=str(exc)) finally: db.close() + + +@router.post("/{tx_id}/ai-assist") +def ai_assist( + request: Request, + tx_id: int, + client_id: int = Form(...), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, denied = _require_partner(request, db, "accounting.learning.manage") + if denied: + return denied + client, _, scope = _find_visible_client(db, request, user, client_id) + if not client: + return _go(error="Client is not visible.") + decision = ai_assist_transaction( + db, + tx_id=tx_id, + tenant_id=scope.tenant_id, + client_id=client.id, + user_id=user.id, + ) + return _go( + client.id, + message=f"AI semantic fallback completed at {decision.combined_confidence}% combined confidence.", + ) + except Exception as exc: + db.rollback() + return _go(client_id, error=str(exc)) + finally: + db.close() diff --git a/app/modules/accounting/purchase_review_service.py b/app/modules/accounting/purchase_review_service.py index 9bcf5ac..1aef241 100644 --- a/app/modules/accounting/purchase_review_service.py +++ b/app/modules/accounting/purchase_review_service.py @@ -9,6 +9,7 @@ from app.modules.accounting.gstr2b_models import AccountingGSTR2BPurchase from app.modules.accounting.gstr2b_service import nature_lookup, review_purchase from app.modules.accounting.historical_learning_service import active_natures, ledger_mappings from app.modules.accounting.purchase_enrichment_models import AccountingPurchaseEnrichmentRecord +from app.modules.accounting.ai_service import ai_assist_purchase as run_ai_assist_purchase, mark_review_outcome VALID_STATUSES = {"all", "pending_analysis", "suggested", "review_required", "reviewed"} VALID_CONFIDENCE = {"all", "high", "medium", "low"} @@ -133,7 +134,17 @@ def review_one(db, *, tenant_id, client_id, purchase_id, final_nature_id, final_ row=db.execute(select(AccountingGSTR2BPurchase).where(AccountingGSTR2BPurchase.id==purchase_id,AccountingGSTR2BPurchase.tenant_id==tenant_id,AccountingGSTR2BPurchase.client_id==client_id)).scalar_one_or_none() if not row: raise ValueError("Purchase record was not found.") _validate_choice(db,tenant_id=tenant_id,client_id=client_id,purchase=row,nature_id=final_nature_id,ledger_name=final_ledger_name) - return review_purchase(db,row=row,final_nature_id=int(final_nature_id),final_ledger_name=(final_ledger_name or "").strip(),user_id=user_id) + reviewed = review_purchase(db,row=row,final_nature_id=int(final_nature_id),final_ledger_name=(final_ledger_name or "").strip(),user_id=user_id) + mark_review_outcome( + db, + tenant_id=tenant_id, + source_type="gstr2b", + source_record_id=reviewed.id, + final_nature_id=reviewed.final_nature_id, + final_ledger_name=reviewed.final_ledger_name, + user_id=user_id, + ) + return reviewed def bulk_confirm(db, *, tenant_id, client_id, purchase_ids, user_id): @@ -148,6 +159,26 @@ def bulk_confirm(db, *, tenant_id, client_id, purchase_ids, user_id): if row.review_status=="reviewed": continue if not row.suggested_nature_id or int(row.suggested_confidence or 0)<85: raise ValueError(f"Invoice {row.invoice_number} is not eligible for high-confidence bulk confirmation.") _validate_choice(db,tenant_id=tenant_id,client_id=client_id,purchase=row,nature_id=row.suggested_nature_id,ledger_name=row.suggested_ledger_name or "") - review_purchase(db,row=row,final_nature_id=int(row.suggested_nature_id),final_ledger_name=row.suggested_ledger_name or "",user_id=user_id) + reviewed = review_purchase(db,row=row,final_nature_id=int(row.suggested_nature_id),final_ledger_name=row.suggested_ledger_name or "",user_id=user_id) + mark_review_outcome( + db, + tenant_id=tenant_id, + source_type="gstr2b", + source_record_id=reviewed.id, + final_nature_id=reviewed.final_nature_id, + final_ledger_name=reviewed.final_ledger_name, + user_id=user_id, + ) completed+=1 return completed + + +def ai_assist_one(db, *, tenant_id, client_id, purchase_id, user_id): + row = db.execute(select(AccountingGSTR2BPurchase).where( + AccountingGSTR2BPurchase.id == int(purchase_id), + AccountingGSTR2BPurchase.tenant_id == int(tenant_id), + AccountingGSTR2BPurchase.client_id == int(client_id), + )).scalar_one_or_none() + if not row: + raise ValueError("Purchase record was not found.") + return run_ai_assist_purchase(db, row=row, user_id=user_id) diff --git a/app/modules/accounting/purchase_review_ui.py b/app/modules/accounting/purchase_review_ui.py index 2db79f3..7de8bdd 100644 --- a/app/modules/accounting/purchase_review_ui.py +++ b/app/modules/accounting/purchase_review_ui.py @@ -11,7 +11,7 @@ from app.modules.accounting.historical_learning_service import active_natures from app.modules.accounting.ledger_learning_service import available_tally_guids from app.modules.accounting.purchase_review_service import ( review_queue, review_counts, return_periods, nature_maps_for_rows, - explanations_for_rows, mappings_by_nature, review_one, bulk_confirm, + explanations_for_rows, mappings_by_nature, review_one, bulk_confirm, ai_assist_one, ) from app.modules.accounting.ui import _find_visible_client, _require_partner, _visible_clients from app.modules.core.rbac.deps import get_user_permissions, get_user_roles @@ -90,3 +90,52 @@ async def bulk_confirm_rows(request:Request): except Exception as exc: db.rollback(); return _redirect(client_id,filters,error=str(exc)) finally:db.close() + + +@router.post("/purchase/{purchase_id}/ai-assist") +def ai_assist_purchase_row( + request: Request, + purchase_id: int, + client_id: int = Form(...), + tally_guid: str = Form(""), + status: str = Form("all"), + confidence: str = Form("all"), + source: str = Form("all"), + supplier: str = Form(""), + return_period: str = Form(""), + page: int = Form(1), + per_page: int = Form(25), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + filters = { + "tally_guid": tally_guid, "status": status, "confidence": confidence, + "source": source, "supplier": supplier, "return_period": return_period, + "page": page, "per_page": per_page, + } + try: + user, response = _require_partner(request, db, "accounting.learning.manage") + if response: + return response + client, _, scope = _find_visible_client(db, request, user, client_id) + if not client: + from app.core.http_responses import ui_access_denied + return ui_access_denied() + decision = ai_assist_one( + db, + tenant_id=scope.tenant_id, + client_id=client.id, + purchase_id=purchase_id, + user_id=user.id, + ) + return _redirect( + client.id, + filters, + message=f"AI semantic fallback completed at {decision.combined_confidence}% combined confidence.", + ) + except Exception as exc: + db.rollback() + return _redirect(client_id, filters, error=str(exc)) + finally: + db.close() diff --git a/app/modules/accounting/templates/accounting/ai_dashboard.html b/app/modules/accounting/templates/accounting/ai_dashboard.html new file mode 100644 index 0000000..6ac45d8 --- /dev/null +++ b/app/modules/accounting/templates/accounting/ai_dashboard.html @@ -0,0 +1,61 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Accounting Intelligence · Phase 12

+

Server AI Semantic Layer

+

AI is a fallback only. Confirmed mappings, historical evidence and deterministic rules remain primary. AI can select only an existing Accounting Nature; it cannot create ledgers or post to Tally.

+
+ Back to Tally +
+ +
+
+
Provider
{{ provider_status.provider or 'Not configured' }}
+
Model
{{ provider_status.model or '—' }}
+
AI Gate
< {{ provider_status.gate }}% deterministic confidence
+
Status
{{ 'Configured' if provider_status.configured else 'Disabled until server key/model are configured' }}
+
+

Server environment: ACCOUNTING_AI_PROVIDER, ACCOUNTING_AI_MODEL, OPENAI_API_KEY. API keys are never displayed in the ERP UI or sent to Local Agents.

+
+ +
+ + +
+ +
+ {% for label,value in [('AI Calls',summary.total),('Completed',summary.completed),('Reviewed',summary.reviewed),('Accepted',summary.accepted),('Corrected',summary.corrected),('Acceptance %',summary.acceptance_rate)] %} +
{{ label }}
{{ value }}
+ {% endfor %} +
+ +
+

Recent AI decisions

Input tokens {{ summary.input_tokens }} · Output tokens {{ summary.output_tokens }}

+
+ + + + {% for d in decisions %} + + + + + + + + + + {% else %}{% endfor %} + +
WhenSourceModelAIConfidenceReviewReason
{{ d.created_at_utc }}{{ d.source_type }} #{{ d.source_record_id }}{{ d.model_name }}{{ d.ai_nature_code or '—' }}{{ d.ai_confidence }}% AI · {{ d.combined_confidence }}% combined{% if d.suggestion_accepted is sameas true %}Accepted{% elif d.suggestion_accepted is sameas false %}Corrected{% elif d.status=='failed' %}Failed{% else %}Pending{% endif %}{{ d.concise_reason or d.error_message or '—' }}
No AI decisions yet.
+
+
+
+{% endblock %} diff --git a/app/modules/accounting/templates/accounting/bank_posting.html b/app/modules/accounting/templates/accounting/bank_posting.html index 37e5587..2cfd2d0 100644 --- a/app/modules/accounting/templates/accounting/bank_posting.html +++ b/app/modules/accounting/templates/accounting/bank_posting.html @@ -29,6 +29,12 @@
Suggestion
{{ tx.suggested_voucher_type }}
{{ tx.auto_party or '—' }}
{{ tx.suggested_ledger_name or tx.analyzer_ledger or 'Review ledger' }}
Confidence {{ tx.suggested_confidence }}%
{% if tx.review_status != 'reviewed' %} + {% if not tx.contra_pair_id and tx.suggested_confidence < 90 %} +
+ + +
+ {% endif %}
diff --git a/app/modules/accounting/templates/accounting/purchase_review.html b/app/modules/accounting/templates/accounting/purchase_review.html index c9cbf57..79f87bb 100644 --- a/app/modules/accounting/templates/accounting/purchase_review.html +++ b/app/modules/accounting/templates/accounting/purchase_review.html @@ -34,7 +34,14 @@
₹{{ '%.2f'|format(row.taxable_value) }}
Invoice ₹{{ '%.2f'|format(row.invoice_value) }}
{% if suggested %}
{{ suggested.name }}{{ row.suggested_confidence }}%
{{ row.suggested_ledger_name or 'No mapped Tally ledger' }}
{% if explanations.get(row.id) %}
Why?
    {% for x in explanations.get(row.id)[:5] %}
  • • {{ x }}
  • {% endfor %}
{% endif %}{% else %}Review required{% endif %} {% if row.review_status=='reviewed' %}
Reviewed
{{ final.name if final else '-' }}
{{ row.final_ledger_name or 'No ledger' }}
{% else %}Awaiting review{% endif %} - {% for k in ['tally_guid','status','confidence','source','supplier','return_period','page','per_page'] %}{% endfor %}
+ + {% if row.review_status!='reviewed' and row.suggested_confidence < 90 %} +
+ {% for k in ['tally_guid','status','confidence','source','supplier','return_period','page','per_page'] %}{% endfor %} + +
+ {% endif %} +
{% for k in ['tally_guid','status','confidence','source','supplier','return_period','page','per_page'] %}{% endfor %}
{% else %}No purchases match the selected filters.{% endfor %}
{% if result.pages>1 %}
Page {{ result.page }} of {{ result.pages }}
{% if prev_url %}Previous{% endif %}{% if next_url %}Next{% endif %}
{% endif %} diff --git a/app/modules/accounting/templates/accounting/tally.html b/app/modules/accounting/templates/accounting/tally.html index 4ad7d1c..037ba31 100644 --- a/app/modules/accounting/templates/accounting/tally.html +++ b/app/modules/accounting/templates/accounting/tally.html @@ -21,6 +21,7 @@ {% if selected_client %}Purchase Review{% endif %} {% if selected_client %}Post to Tally{% endif %} {% if selected_client %}Bank → Tally{% endif %} + Accounting AI {% if selected_client %}Depreciation (IT){% endif %} Refresh Tally Companies diff --git a/app/ui/app.py b/app/ui/app.py index 8fa3fed..bccd6eb 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -45,6 +45,7 @@ from app.modules.accounting.purchase_enrichment_ui import router as accounting_p from app.modules.accounting.purchase_review_ui import router as accounting_purchase_review_ui_router from app.modules.accounting.purchase_posting_ui import router as accounting_purchase_posting_ui_router from app.modules.accounting.bank_ui import router as accounting_bank_ui_router +from app.modules.accounting.ai_ui import router as accounting_ai_ui_router from app.modules.registrations.ui import router as registrations_ui_router from app.modules.credential_vault.ui import router as credential_vault_ui_router from app.modules.client_identity.ui import router as client_identity_ui_router @@ -76,6 +77,7 @@ def mount_ui(app: FastAPI) -> None: app.include_router(accounting_purchase_review_ui_router) app.include_router(accounting_purchase_posting_ui_router) app.include_router(accounting_bank_ui_router) + app.include_router(accounting_ai_ui_router) app.include_router(work_tracker_ui_router) app.include_router(billing_ui_router) app.include_router(platform_billing_ui_router)