Add Phase 12 server AI semantic accounting layer
This commit is contained in:
@@ -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")
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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."
|
||||||
|
)
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -9,6 +9,7 @@ import pandas as pd
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from app.modules.accounting.bank_models import AccountingBankTransaction
|
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 (
|
from app.modules.accounting.ledger_learning_service import (
|
||||||
active_natures,
|
active_natures,
|
||||||
available_tally_guids,
|
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.add(tx)
|
||||||
db.commit()
|
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
|
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):
|
def visible_workstations(db, tenant_id, branch_id=None):
|
||||||
stmt = select(ERPWorkstationAgent).where(
|
stmt = select(ERPWorkstationAgent).where(
|
||||||
ERPWorkstationAgent.tenant_id == tenant_id,
|
ERPWorkstationAgent.tenant_id == tenant_id,
|
||||||
|
|||||||
@@ -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.security.csrf import get_or_create_csrf_token, validate_csrf
|
||||||
from app.core.templating import templates
|
from app.core.templating import templates
|
||||||
from app.modules.accounting.bank_service import (
|
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,
|
queue_rows, sync_posting, visible_workstations,
|
||||||
)
|
)
|
||||||
from app.modules.accounting.ledger_learning_service import active_natures
|
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:
|
except Exception as exc:
|
||||||
db.rollback(); return _go(client_id, error=str(exc))
|
db.rollback(); return _go(client_id, error=str(exc))
|
||||||
finally: db.close()
|
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()
|
||||||
|
|||||||
@@ -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.gstr2b_service import nature_lookup, review_purchase
|
||||||
from app.modules.accounting.historical_learning_service import active_natures, ledger_mappings
|
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.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_STATUSES = {"all", "pending_analysis", "suggested", "review_required", "reviewed"}
|
||||||
VALID_CONFIDENCE = {"all", "high", "medium", "low"}
|
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()
|
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.")
|
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)
|
_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):
|
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 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.")
|
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 "")
|
_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
|
completed+=1
|
||||||
return completed
|
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)
|
||||||
|
|||||||
@@ -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.ledger_learning_service import available_tally_guids
|
||||||
from app.modules.accounting.purchase_review_service import (
|
from app.modules.accounting.purchase_review_service import (
|
||||||
review_queue, review_counts, return_periods, nature_maps_for_rows,
|
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.accounting.ui import _find_visible_client, _require_partner, _visible_clients
|
||||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
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:
|
except Exception as exc:
|
||||||
db.rollback(); return _redirect(client_id,filters,error=str(exc))
|
db.rollback(); return _redirect(client_id,filters,error=str(exc))
|
||||||
finally:db.close()
|
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()
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{% extends "ui/templates/base/layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Accounting Intelligence · Phase 12</p>
|
||||||
|
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Server AI Semantic Layer</h1>
|
||||||
|
<p class="mt-1 max-w-4xl text-sm text-slate-500">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.</p>
|
||||||
|
</div>
|
||||||
|
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold">Back to Tally</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||||
|
<div class="grid gap-4 md:grid-cols-4">
|
||||||
|
<div><div class="text-xs uppercase text-slate-500">Provider</div><div class="mt-1 font-semibold">{{ provider_status.provider or 'Not configured' }}</div></div>
|
||||||
|
<div><div class="text-xs uppercase text-slate-500">Model</div><div class="mt-1 font-semibold">{{ provider_status.model or '—' }}</div></div>
|
||||||
|
<div><div class="text-xs uppercase text-slate-500">AI Gate</div><div class="mt-1 font-semibold">< {{ provider_status.gate }}% deterministic confidence</div></div>
|
||||||
|
<div><div class="text-xs uppercase text-slate-500">Status</div><div class="mt-1 font-semibold {% if provider_status.configured %}text-emerald-700{% else %}text-amber-700{% endif %}">{{ 'Configured' if provider_status.configured else 'Disabled until server key/model are configured' }}</div></div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-xs text-slate-500">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.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form method="get" class="rounded-2xl bg-white p-5 shadow-soft">
|
||||||
|
<label class="text-sm font-semibold">Client
|
||||||
|
<select name="client_id" class="mt-1 w-full max-w-xl rounded-xl border border-slate-300 bg-white px-3 py-2">
|
||||||
|
<option value="">All visible clients</option>
|
||||||
|
{% for c in clients %}<option value="{{ c.id }}" {% if selected_client and selected_client.id == c.id %}selected{% endif %}>{{ c.client_name }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="mt-3 rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Apply</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-6">
|
||||||
|
{% for label,value in [('AI Calls',summary.total),('Completed',summary.completed),('Reviewed',summary.reviewed),('Accepted',summary.accepted),('Corrected',summary.corrected),('Acceptance %',summary.acceptance_rate)] %}
|
||||||
|
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">{{ label }}</div><div class="mt-2 text-2xl font-semibold">{{ value }}</div></div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-2xl bg-white shadow-soft">
|
||||||
|
<div class="border-b border-slate-200 px-5 py-4"><h2 class="font-semibold">Recent AI decisions</h2><p class="text-sm text-slate-500">Input tokens {{ summary.input_tokens }} · Output tokens {{ summary.output_tokens }}</p></div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead><tr class="border-b bg-slate-50 text-left text-xs uppercase text-slate-500"><th class="px-3 py-3">When</th><th class="px-3 py-3">Source</th><th class="px-3 py-3">Model</th><th class="px-3 py-3">AI</th><th class="px-3 py-3">Confidence</th><th class="px-3 py-3">Review</th><th class="px-3 py-3">Reason</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in decisions %}
|
||||||
|
<tr class="border-b border-slate-100 align-top">
|
||||||
|
<td class="px-3 py-3 whitespace-nowrap">{{ d.created_at_utc }}</td>
|
||||||
|
<td class="px-3 py-3">{{ d.source_type }} #{{ d.source_record_id }}</td>
|
||||||
|
<td class="px-3 py-3">{{ d.model_name }}</td>
|
||||||
|
<td class="px-3 py-3">{{ d.ai_nature_code or '—' }}</td>
|
||||||
|
<td class="px-3 py-3">{{ d.ai_confidence }}% AI · {{ d.combined_confidence }}% combined</td>
|
||||||
|
<td class="px-3 py-3">{% if d.suggestion_accepted is sameas true %}<span class="text-emerald-700">Accepted</span>{% elif d.suggestion_accepted is sameas false %}<span class="text-amber-700">Corrected</span>{% elif d.status=='failed' %}<span class="text-red-700">Failed</span>{% else %}Pending{% endif %}</td>
|
||||||
|
<td class="px-3 py-3 max-w-xl">{{ d.concise_reason or d.error_message or '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}<tr><td colspan="7" class="px-4 py-10 text-center text-slate-500">No AI decisions yet.</td></tr>{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -29,6 +29,12 @@
|
|||||||
<div class="xl:col-span-3"><div class="text-xs uppercase text-slate-500">Suggestion</div><div class="mt-1 font-semibold">{{ tx.suggested_voucher_type }}</div><div class="text-sm">{{ tx.auto_party or '—' }}</div><div class="text-sm">{{ tx.suggested_ledger_name or tx.analyzer_ledger or 'Review ledger' }}</div><div class="text-xs text-slate-500">Confidence {{ tx.suggested_confidence }}%</div></div>
|
<div class="xl:col-span-3"><div class="text-xs uppercase text-slate-500">Suggestion</div><div class="mt-1 font-semibold">{{ tx.suggested_voucher_type }}</div><div class="text-sm">{{ tx.auto_party or '—' }}</div><div class="text-sm">{{ tx.suggested_ledger_name or tx.analyzer_ledger or 'Review ledger' }}</div><div class="text-xs text-slate-500">Confidence {{ tx.suggested_confidence }}%</div></div>
|
||||||
<div class="xl:col-span-6">
|
<div class="xl:col-span-6">
|
||||||
{% if tx.review_status != 'reviewed' %}
|
{% if tx.review_status != 'reviewed' %}
|
||||||
|
{% if not tx.contra_pair_id and tx.suggested_confidence < 90 %}
|
||||||
|
<form method="post" action="/tools/accounting/bank-posting/{{ tx.id }}/ai-assist" class="mb-2">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
||||||
|
<button class="w-full rounded-lg border border-indigo-300 bg-indigo-50 px-3 py-2 text-sm font-semibold text-indigo-800">AI Semantic Assist</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
<form method="post" action="/tools/accounting/bank-posting/{{ tx.id }}/review" class="grid gap-2 md:grid-cols-2">
|
<form method="post" action="/tools/accounting/bank-posting/{{ tx.id }}/review" class="grid gap-2 md:grid-cols-2">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
||||||
<label class="text-xs font-semibold">Voucher Type<select name="voucher_type" class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">{% for v in ['Payment','Receipt','Contra'] %}<option {% if tx.suggested_voucher_type==v %}selected{% endif %}>{{ v }}</option>{% endfor %}</select></label>
|
<label class="text-xs font-semibold">Voucher Type<select name="voucher_type" class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">{% for v in ['Payment','Receipt','Contra'] %}<option {% if tx.suggested_voucher_type==v %}selected{% endif %}>{{ v }}</option>{% endfor %}</select></label>
|
||||||
|
|||||||
@@ -34,7 +34,14 @@
|
|||||||
<td class="px-3 py-4 text-right whitespace-nowrap"><div class="font-semibold">₹{{ '%.2f'|format(row.taxable_value) }}</div><div class="text-xs text-slate-500">Invoice ₹{{ '%.2f'|format(row.invoice_value) }}</div></td>
|
<td class="px-3 py-4 text-right whitespace-nowrap"><div class="font-semibold">₹{{ '%.2f'|format(row.taxable_value) }}</div><div class="text-xs text-slate-500">Invoice ₹{{ '%.2f'|format(row.invoice_value) }}</div></td>
|
||||||
<td class="px-3 py-4 min-w-[250px]">{% if suggested %}<div class="flex gap-2"><span class="font-semibold">{{ suggested.name }}</span><span class="rounded-full px-2 py-1 text-xs font-semibold {% if row.suggested_confidence>=85 %}bg-emerald-50 text-emerald-700{% elif row.suggested_confidence>=60 %}bg-blue-50 text-blue-700{% else %}bg-amber-50 text-amber-700{% endif %}">{{ row.suggested_confidence }}%</span></div><div class="text-sm text-slate-600">{{ row.suggested_ledger_name or 'No mapped Tally ledger' }}</div>{% if explanations.get(row.id) %}<details class="mt-2"><summary class="cursor-pointer text-xs font-semibold text-brand-600">Why?</summary><ul class="mt-1 text-xs text-slate-500">{% for x in explanations.get(row.id)[:5] %}<li>• {{ x }}</li>{% endfor %}</ul></details>{% endif %}{% else %}<span class="rounded-full bg-amber-50 px-2 py-1 text-xs font-semibold text-amber-700">Review required</span>{% endif %}</td>
|
<td class="px-3 py-4 min-w-[250px]">{% if suggested %}<div class="flex gap-2"><span class="font-semibold">{{ suggested.name }}</span><span class="rounded-full px-2 py-1 text-xs font-semibold {% if row.suggested_confidence>=85 %}bg-emerald-50 text-emerald-700{% elif row.suggested_confidence>=60 %}bg-blue-50 text-blue-700{% else %}bg-amber-50 text-amber-700{% endif %}">{{ row.suggested_confidence }}%</span></div><div class="text-sm text-slate-600">{{ row.suggested_ledger_name or 'No mapped Tally ledger' }}</div>{% if explanations.get(row.id) %}<details class="mt-2"><summary class="cursor-pointer text-xs font-semibold text-brand-600">Why?</summary><ul class="mt-1 text-xs text-slate-500">{% for x in explanations.get(row.id)[:5] %}<li>• {{ x }}</li>{% endfor %}</ul></details>{% endif %}{% else %}<span class="rounded-full bg-amber-50 px-2 py-1 text-xs font-semibold text-amber-700">Review required</span>{% endif %}</td>
|
||||||
<td class="px-3 py-4 min-w-[220px]">{% if row.review_status=='reviewed' %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-3"><div class="text-xs font-semibold uppercase text-emerald-700">Reviewed</div><div class="font-semibold">{{ final.name if final else '-' }}</div><div class="text-sm text-slate-600">{{ row.final_ledger_name or 'No ledger' }}</div></div>{% else %}<span class="text-xs text-slate-500">Awaiting review</span>{% endif %}</td>
|
<td class="px-3 py-4 min-w-[220px]">{% if row.review_status=='reviewed' %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-3"><div class="text-xs font-semibold uppercase text-emerald-700">Reviewed</div><div class="font-semibold">{{ final.name if final else '-' }}</div><div class="text-sm text-slate-600">{{ row.final_ledger_name or 'No ledger' }}</div></div>{% else %}<span class="text-xs text-slate-500">Awaiting review</span>{% endif %}</td>
|
||||||
<td class="px-3 py-4 min-w-[330px]"><form method="post" action="/tools/accounting/purchase-review/purchase/{{ row.id }}/review" class="space-y-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">{% for k in ['tally_guid','status','confidence','source','supplier','return_period','page','per_page'] %}<input type="hidden" name="{{ k }}" value="{{ filters[k] }}">{% endfor %}<select name="final_nature_id" required class="w-full rounded-lg border border-slate-300 bg-white px-2 py-2 text-sm">{% for nature in natures %}<option value="{{ nature.id }}" {% if (final and final.id==nature.id) or (not final and suggested and suggested.id==nature.id) %}selected{% endif %}>{{ nature.name }}</option>{% endfor %}</select><select name="final_ledger_name" class="w-full rounded-lg border border-slate-300 bg-white px-2 py-2 text-sm"><option value="">No mapped ledger yet</option>{% for nature_id, maps in mappings_by_nature.items() %}{% for m in maps %}<option value="{{ m.ledger_name }}" {% if row.final_ledger_name==m.ledger_name or (not row.final_ledger_name and row.suggested_ledger_name==m.ledger_name) %}selected{% endif %}>{{ m.ledger_name }}</option>{% endfor %}{% endfor %}</select><button class="w-full rounded-lg bg-brand-600 px-3 py-2 text-sm font-semibold text-white">{{ 'Update Review' if row.review_status=='reviewed' else 'Confirm / Correct' }}</button></form></td></tr>
|
<td class="px-3 py-4 min-w-[330px]">
|
||||||
|
{% if row.review_status!='reviewed' and row.suggested_confidence < 90 %}
|
||||||
|
<form method="post" action="/tools/accounting/purchase-review/purchase/{{ row.id }}/ai-assist" class="mb-2">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">{% for k in ['tally_guid','status','confidence','source','supplier','return_period','page','per_page'] %}<input type="hidden" name="{{ k }}" value="{{ filters[k] }}">{% endfor %}
|
||||||
|
<button class="w-full rounded-lg border border-indigo-300 bg-indigo-50 px-3 py-2 text-sm font-semibold text-indigo-800">AI Semantic Assist</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/tools/accounting/purchase-review/purchase/{{ row.id }}/review" class="space-y-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">{% for k in ['tally_guid','status','confidence','source','supplier','return_period','page','per_page'] %}<input type="hidden" name="{{ k }}" value="{{ filters[k] }}">{% endfor %}<select name="final_nature_id" required class="w-full rounded-lg border border-slate-300 bg-white px-2 py-2 text-sm">{% for nature in natures %}<option value="{{ nature.id }}" {% if (final and final.id==nature.id) or (not final and suggested and suggested.id==nature.id) %}selected{% endif %}>{{ nature.name }}</option>{% endfor %}</select><select name="final_ledger_name" class="w-full rounded-lg border border-slate-300 bg-white px-2 py-2 text-sm"><option value="">No mapped ledger yet</option>{% for nature_id, maps in mappings_by_nature.items() %}{% for m in maps %}<option value="{{ m.ledger_name }}" {% if row.final_ledger_name==m.ledger_name or (not row.final_ledger_name and row.suggested_ledger_name==m.ledger_name) %}selected{% endif %}>{{ m.ledger_name }}</option>{% endfor %}{% endfor %}</select><button class="w-full rounded-lg bg-brand-600 px-3 py-2 text-sm font-semibold text-white">{{ 'Update Review' if row.review_status=='reviewed' else 'Confirm / Correct' }}</button></form></td></tr>
|
||||||
{% else %}<tr><td colspan="7" class="px-4 py-10 text-center text-slate-500">No purchases match the selected filters.</td></tr>{% endfor %}</tbody></table></div>
|
{% else %}<tr><td colspan="7" class="px-4 py-10 text-center text-slate-500">No purchases match the selected filters.</td></tr>{% endfor %}</tbody></table></div>
|
||||||
{% if result.pages>1 %}<div class="flex items-center justify-between border-t border-slate-200 px-5 py-4"><span class="text-sm text-slate-500">Page {{ result.page }} of {{ result.pages }}</span><div class="flex gap-2">{% if prev_url %}<a href="{{ prev_url }}" class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold">Previous</a>{% endif %}{% if next_url %}<a href="{{ next_url }}" class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold">Next</a>{% endif %}</div></div>{% endif %}</section>
|
{% if result.pages>1 %}<div class="flex items-center justify-between border-t border-slate-200 px-5 py-4"><span class="text-sm text-slate-500">Page {{ result.page }} of {{ result.pages }}</span><div class="flex gap-2">{% if prev_url %}<a href="{{ prev_url }}" class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold">Previous</a>{% endif %}{% if next_url %}<a href="{{ next_url }}" class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold">Next</a>{% endif %}</div></div>{% endif %}</section>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
{% if selected_client %}<a href="/tools/accounting/purchase-review?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Purchase Review</a>{% endif %}
|
{% if selected_client %}<a href="/tools/accounting/purchase-review?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Purchase Review</a>{% endif %}
|
||||||
{% if selected_client %}<a href="/tools/accounting/purchase-posting?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Post to Tally</a>{% endif %}
|
{% if selected_client %}<a href="/tools/accounting/purchase-posting?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Post to Tally</a>{% endif %}
|
||||||
{% if selected_client %}<a href="/tools/accounting/bank-posting?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Bank → Tally</a>{% endif %}
|
{% if selected_client %}<a href="/tools/accounting/bank-posting?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Bank → Tally</a>{% endif %}
|
||||||
|
<a href="/tools/accounting/ai{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-indigo-300 bg-indigo-50 px-4 py-2 text-sm font-semibold text-indigo-800 hover:bg-indigo-100">Accounting AI</a>
|
||||||
{% if selected_client %}<a href="/tools/tally/depreciation?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Depreciation (IT)</a>{% endif %}
|
{% if selected_client %}<a href="/tools/tally/depreciation?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Depreciation (IT)</a>{% endif %}
|
||||||
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}&refresh=1{% else %}?refresh=1{% endif %}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Refresh Tally Companies</a>
|
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}&refresh=1{% else %}?refresh=1{% endif %}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Refresh Tally Companies</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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_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.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.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.registrations.ui import router as registrations_ui_router
|
||||||
from app.modules.credential_vault.ui import router as credential_vault_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
|
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_review_ui_router)
|
||||||
app.include_router(accounting_purchase_posting_ui_router)
|
app.include_router(accounting_purchase_posting_ui_router)
|
||||||
app.include_router(accounting_bank_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(work_tracker_ui_router)
|
||||||
app.include_router(billing_ui_router)
|
app.include_router(billing_ui_router)
|
||||||
app.include_router(platform_billing_ui_router)
|
app.include_router(platform_billing_ui_router)
|
||||||
|
|||||||
Reference in New Issue
Block a user