Add Phase 12 server AI semantic accounting layer
This commit is contained in:
@@ -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 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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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-6">
|
||||
{% 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">
|
||||
<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>
|
||||
|
||||
@@ -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 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-[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>
|
||||
{% 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>
|
||||
|
||||
@@ -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-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 %}
|
||||
<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 %}
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user