bina soke kam kia
Browse files- agent/__pycache__/extraction_agent.cpython-311.pyc +0 -0
- agent/__pycache__/intent_agent.cpython-311.pyc +0 -0
- agent/__pycache__/intent_agent.cpython-312.pyc +0 -0
- agent/__pycache__/orchestrator.cpython-311.pyc +0 -0
- agent/__pycache__/orchestrator.cpython-312.pyc +0 -0
- agent/extraction_agent.py +133 -6
- agent/intent_agent.py +87 -6
- agent/orchestrator.py +114 -6
- agent/router.py +80 -6
- agent/skill_generator.py +207 -0
- app/__pycache__/bedrock_client.cpython-311.pyc +0 -0
- app/__pycache__/bedrock_client.cpython-312.pyc +0 -0
- app/__pycache__/config.cpython-311.pyc +0 -0
- app/__pycache__/config.cpython-312.pyc +0 -0
- app/__pycache__/main.cpython-311.pyc +0 -0
- app/__pycache__/main.cpython-312.pyc +0 -0
- app/bedrock_client.py +95 -0
- app/config.py +44 -4
- app/main.py +198 -5
- dashboard/streamlit_app.py +325 -4
- memory/agent_memory.py +145 -0
- parsers/__pycache__/message_parser.cpython-311.pyc +0 -0
- parsers/message_parser.py +22 -5
- prompts/extraction_prompt.txt +120 -7
- prompts/intent_prompt.txt +62 -6
- services/inventory_service.py +143 -6
- services/invoice_service.py +93 -6
- skills/credit_skill.py +66 -6
- skills/order_skill.py +93 -6
- skills/payment_skill.py +65 -6
- skills/preparation_skill.py +117 -0
- skills/return_skill.py +100 -6
- utils/excel_writer.py +161 -6
- validators/__pycache__/data_validator.cpython-311.pyc +0 -0
- validators/data_validator.py +124 -5
agent/__pycache__/extraction_agent.cpython-311.pyc
ADDED
|
Binary file (8.44 kB). View file
|
|
|
agent/__pycache__/intent_agent.cpython-311.pyc
ADDED
|
Binary file (5.28 kB). View file
|
|
|
agent/__pycache__/intent_agent.cpython-312.pyc
ADDED
|
Binary file (4.59 kB). View file
|
|
|
agent/__pycache__/orchestrator.cpython-311.pyc
ADDED
|
Binary file (2.68 kB). View file
|
|
|
agent/__pycache__/orchestrator.cpython-312.pyc
ADDED
|
Binary file (3.56 kB). View file
|
|
|
agent/extraction_agent.py
CHANGED
|
@@ -1,9 +1,136 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
"""
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
Field extraction agent for Notiflow.
|
| 3 |
"""
|
| 4 |
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
import re
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from botocore.exceptions import BotoCoreError, ClientError
|
| 13 |
+
|
| 14 |
+
from app.bedrock_client import get_bedrock_client
|
| 15 |
+
from app.config import MODEL_ID
|
| 16 |
+
|
| 17 |
+
PROMPT_PATH = Path(__file__).parent.parent / "prompts" / "extraction_prompt.txt"
|
| 18 |
+
|
| 19 |
+
INTENT_SCHEMA: dict[str, list[str]] = {
|
| 20 |
+
"order": ["intent", "customer", "item", "quantity"],
|
| 21 |
+
"payment": ["intent", "customer", "amount", "payment_type"],
|
| 22 |
+
"credit": ["intent", "customer", "item", "quantity", "amount"],
|
| 23 |
+
"return": ["intent", "customer", "item", "reason"],
|
| 24 |
+
"preparation": ["intent", "item", "quantity"],
|
| 25 |
+
"other": ["intent", "note"],
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
VALID_INTENTS = set(INTENT_SCHEMA.keys())
|
| 29 |
+
logger = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _get_bedrock_client():
|
| 33 |
+
"""Return a cached Bedrock runtime client."""
|
| 34 |
+
return get_bedrock_client()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _load_prompt(message: str, intent: str) -> str:
|
| 38 |
+
"""Load the extraction prompt template and inject the message and intent."""
|
| 39 |
+
template = PROMPT_PATH.read_text(encoding="utf-8")
|
| 40 |
+
prompt = template.replace("{message}", message.strip())
|
| 41 |
+
return prompt.replace("{intent}", intent.strip().lower())
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _call_nova(prompt: str) -> str:
|
| 45 |
+
"""Send a prompt to Amazon Nova 2 Lite via Bedrock Converse API."""
|
| 46 |
+
client = _get_bedrock_client()
|
| 47 |
+
request_body = {
|
| 48 |
+
"messages": [{"role": "user", "content": [{"text": prompt}]}],
|
| 49 |
+
"inferenceConfig": {
|
| 50 |
+
"maxTokens": 256,
|
| 51 |
+
"temperature": 0.0,
|
| 52 |
+
"topP": 1.0,
|
| 53 |
+
},
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
response = client.converse(modelId=MODEL_ID, **request_body)
|
| 58 |
+
output_message = response["output"]["message"]
|
| 59 |
+
text_parts = [block["text"] for block in output_message["content"] if "text" in block]
|
| 60 |
+
return " ".join(text_parts).strip()
|
| 61 |
+
except (BotoCoreError, ClientError) as exc:
|
| 62 |
+
logger.error("Bedrock API error: %s", exc)
|
| 63 |
+
raise RuntimeError(f"Failed to call Amazon Nova: {exc}") from exc
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _parse_extraction_response(raw: str, intent: str) -> dict:
|
| 67 |
+
"""Parse model output into a schema-conformant dict."""
|
| 68 |
+
cleaned = re.sub(r"```(?:json)?|```", "", raw).strip()
|
| 69 |
+
|
| 70 |
+
try:
|
| 71 |
+
parsed = json.loads(cleaned)
|
| 72 |
+
except json.JSONDecodeError:
|
| 73 |
+
match = re.search(r"\{.*\}", cleaned, re.DOTALL)
|
| 74 |
+
if match:
|
| 75 |
+
try:
|
| 76 |
+
parsed = json.loads(match.group(0))
|
| 77 |
+
except json.JSONDecodeError:
|
| 78 |
+
logger.warning("Could not parse Nova response as JSON; returning nulls")
|
| 79 |
+
parsed = {}
|
| 80 |
+
else:
|
| 81 |
+
parsed = {}
|
| 82 |
+
|
| 83 |
+
schema_fields = INTENT_SCHEMA.get(intent, INTENT_SCHEMA["other"])
|
| 84 |
+
result = {field: parsed.get(field, None) for field in schema_fields}
|
| 85 |
+
result["intent"] = intent
|
| 86 |
+
|
| 87 |
+
if "customer" in result and isinstance(result["customer"], str):
|
| 88 |
+
result["customer"] = result["customer"].strip().title()
|
| 89 |
+
|
| 90 |
+
if "amount" in result and result["amount"] is not None:
|
| 91 |
+
try:
|
| 92 |
+
result["amount"] = float(result["amount"])
|
| 93 |
+
if result["amount"].is_integer():
|
| 94 |
+
result["amount"] = int(result["amount"])
|
| 95 |
+
except (ValueError, TypeError):
|
| 96 |
+
result["amount"] = None
|
| 97 |
+
|
| 98 |
+
if "quantity" in result and result["quantity"] is not None:
|
| 99 |
+
try:
|
| 100 |
+
result["quantity"] = float(result["quantity"])
|
| 101 |
+
if result["quantity"].is_integer():
|
| 102 |
+
result["quantity"] = int(result["quantity"])
|
| 103 |
+
except (ValueError, TypeError):
|
| 104 |
+
result["quantity"] = None
|
| 105 |
+
|
| 106 |
+
return result
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def extract_fields(message: str, intent: str) -> dict:
|
| 110 |
+
"""Extract structured business fields from a Hinglish message."""
|
| 111 |
+
if not message or not message.strip():
|
| 112 |
+
logger.warning("Empty message received")
|
| 113 |
+
return _null_result(intent)
|
| 114 |
+
|
| 115 |
+
intent = intent.lower().strip()
|
| 116 |
+
if intent not in VALID_INTENTS:
|
| 117 |
+
raise ValueError(
|
| 118 |
+
f"Unsupported intent: '{intent}'. Must be one of: {', '.join(sorted(VALID_INTENTS))}"
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
logger.info("Extracting fields | intent=%s | message=%r", intent, message)
|
| 122 |
+
prompt = _load_prompt(message, intent)
|
| 123 |
+
raw_response = _call_nova(prompt)
|
| 124 |
+
logger.debug("Raw Nova response: %r", raw_response)
|
| 125 |
+
result = _parse_extraction_response(raw_response, intent)
|
| 126 |
+
logger.info("Extracted fields: %s", result)
|
| 127 |
+
return result
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _null_result(intent: str) -> dict:
|
| 131 |
+
"""Return a fully-null result for the given intent."""
|
| 132 |
+
intent = intent.lower().strip() if intent in VALID_INTENTS else "other"
|
| 133 |
+
schema_fields = INTENT_SCHEMA.get(intent, INTENT_SCHEMA["other"])
|
| 134 |
+
result = {field: None for field in schema_fields}
|
| 135 |
+
result["intent"] = intent
|
| 136 |
+
return result
|
agent/intent_agent.py
CHANGED
|
@@ -1,9 +1,90 @@
|
|
| 1 |
"""
|
| 2 |
-
Intent agent for
|
| 3 |
"""
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
Intent detection agent for Notiflow.
|
| 3 |
"""
|
| 4 |
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
import re
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from botocore.exceptions import BotoCoreError, ClientError
|
| 13 |
+
|
| 14 |
+
from app.bedrock_client import get_bedrock_client
|
| 15 |
+
from app.config import MODEL_ID
|
| 16 |
+
|
| 17 |
+
PROMPT_PATH = Path(__file__).parent.parent / "prompts" / "intent_prompt.txt"
|
| 18 |
+
VALID_INTENTS = {"order", "payment", "credit", "return", "preparation", "other"}
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _get_bedrock_client():
|
| 24 |
+
"""Return a cached Bedrock runtime client."""
|
| 25 |
+
return get_bedrock_client()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _load_prompt(message: str) -> str:
|
| 29 |
+
"""Load the intent prompt template and inject the user message."""
|
| 30 |
+
template = PROMPT_PATH.read_text(encoding="utf-8")
|
| 31 |
+
return template.replace("{message}", message.strip())
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _call_nova(prompt: str) -> str:
|
| 35 |
+
"""Send a prompt to Amazon Nova 2 Lite via Bedrock Converse API."""
|
| 36 |
+
client = _get_bedrock_client()
|
| 37 |
+
request_body = {
|
| 38 |
+
"messages": [{"role": "user", "content": [{"text": prompt}]}],
|
| 39 |
+
"inferenceConfig": {
|
| 40 |
+
"maxTokens": 64,
|
| 41 |
+
"temperature": 0.0,
|
| 42 |
+
"topP": 1.0,
|
| 43 |
+
},
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
try:
|
| 47 |
+
response = client.converse(modelId=MODEL_ID, **request_body)
|
| 48 |
+
output_message = response["output"]["message"]
|
| 49 |
+
text_parts = [
|
| 50 |
+
block["text"]
|
| 51 |
+
for block in output_message["content"]
|
| 52 |
+
if block.get("type") == "text" or "text" in block
|
| 53 |
+
]
|
| 54 |
+
return " ".join(text_parts).strip()
|
| 55 |
+
except (BotoCoreError, ClientError) as exc:
|
| 56 |
+
logger.error("Bedrock API error: %s", exc)
|
| 57 |
+
raise RuntimeError(f"Failed to call Amazon Nova: {exc}") from exc
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _parse_intent_response(raw: str) -> dict[str, str]:
|
| 61 |
+
"""Parse model output into a validated intent dict."""
|
| 62 |
+
cleaned = re.sub(r"```(?:json)?|```", "", raw).strip()
|
| 63 |
+
|
| 64 |
+
try:
|
| 65 |
+
result = json.loads(cleaned)
|
| 66 |
+
intent = result.get("intent", "other").lower().strip()
|
| 67 |
+
except json.JSONDecodeError:
|
| 68 |
+
match = re.search(r'"intent"\s*:\s*"(\w+)"', cleaned)
|
| 69 |
+
intent = match.group(1).lower() if match else "other"
|
| 70 |
+
|
| 71 |
+
if intent not in VALID_INTENTS:
|
| 72 |
+
logger.warning("Model returned unknown intent '%s', defaulting to 'other'", intent)
|
| 73 |
+
intent = "other"
|
| 74 |
+
|
| 75 |
+
return {"intent": intent}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def detect_intent(message: str) -> dict[str, str]:
|
| 79 |
+
"""Detect the business intent of a Hinglish message."""
|
| 80 |
+
if not message or not message.strip():
|
| 81 |
+
logger.warning("Empty message received, returning 'other'")
|
| 82 |
+
return {"intent": "other"}
|
| 83 |
+
|
| 84 |
+
logger.info("Detecting intent for message: %r", message)
|
| 85 |
+
prompt = _load_prompt(message)
|
| 86 |
+
raw_response = _call_nova(prompt)
|
| 87 |
+
logger.debug("Raw Nova response: %r", raw_response)
|
| 88 |
+
result = _parse_intent_response(raw_response)
|
| 89 |
+
logger.info("Detected intent: %s", result["intent"])
|
| 90 |
+
return result
|
agent/orchestrator.py
CHANGED
|
@@ -1,9 +1,117 @@
|
|
| 1 |
"""
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
orchestrator.py
|
| 3 |
+
---------------
|
| 4 |
+
Agent Orchestrator for Notiflow (Stages 4 + 5 + FIX 2)
|
| 5 |
+
|
| 6 |
+
Pipeline:
|
| 7 |
+
raw message
|
| 8 |
+
β
|
| 9 |
+
βΌ
|
| 10 |
+
Intent Agent β detect intent
|
| 11 |
+
β
|
| 12 |
+
βΌ
|
| 13 |
+
Extraction Agent β extract structured fields
|
| 14 |
+
β
|
| 15 |
+
βΌ
|
| 16 |
+
Validator β normalise numbers, text, payment aliases β NEW
|
| 17 |
+
β
|
| 18 |
+
βΌ
|
| 19 |
+
Skill Router β dispatch to business skill + persist
|
| 20 |
+
β
|
| 21 |
+
βΌ
|
| 22 |
+
Structured Result β returned to caller
|
| 23 |
+
|
| 24 |
+
Return shape (unchanged contract):
|
| 25 |
+
{
|
| 26 |
+
"message": str,
|
| 27 |
+
"intent": str,
|
| 28 |
+
"data": dict, β validated extracted fields
|
| 29 |
+
"event": dict, β skill event output
|
| 30 |
+
}
|
| 31 |
"""
|
| 32 |
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
import logging
|
| 36 |
+
from typing import Any
|
| 37 |
+
|
| 38 |
+
from agent.intent_agent import detect_intent
|
| 39 |
+
from agent.extraction_agent import extract_fields
|
| 40 |
+
from validators.data_validator import validate_data
|
| 41 |
+
from agent.router import route_to_skill
|
| 42 |
+
|
| 43 |
+
logger = logging.getLogger(__name__)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
# Internal helpers
|
| 48 |
+
# ---------------------------------------------------------------------------
|
| 49 |
+
|
| 50 |
+
def _build_result(
|
| 51 |
+
message: str,
|
| 52 |
+
intent: str,
|
| 53 |
+
validated: dict,
|
| 54 |
+
skill_event: dict,
|
| 55 |
+
) -> dict:
|
| 56 |
+
"""Assemble the final result object returned to callers."""
|
| 57 |
+
return {
|
| 58 |
+
"message": message,
|
| 59 |
+
"intent": intent,
|
| 60 |
+
"data": validated,
|
| 61 |
+
"event": skill_event,
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ---------------------------------------------------------------------------
|
| 66 |
+
# Public API
|
| 67 |
+
# ---------------------------------------------------------------------------
|
| 68 |
+
|
| 69 |
+
def process_message(message: str) -> dict[str, Any]:
|
| 70 |
+
"""
|
| 71 |
+
Run a raw business message through the full Notiflow agent pipeline.
|
| 72 |
+
|
| 73 |
+
Steps:
|
| 74 |
+
1. detect_intent β Nova classifies the business intent
|
| 75 |
+
2. extract_fields β Nova extracts structured entities
|
| 76 |
+
3. validate_data β normalise numbers, text, payment aliases
|
| 77 |
+
4. route_to_skill β dispatch to correct business skill + persist
|
| 78 |
+
|
| 79 |
+
Args:
|
| 80 |
+
message: Raw Hinglish or English business message.
|
| 81 |
+
|
| 82 |
+
Returns:
|
| 83 |
+
{
|
| 84 |
+
"message": str,
|
| 85 |
+
"intent": str,
|
| 86 |
+
"data": dict,
|
| 87 |
+
"event": dict,
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
Raises:
|
| 91 |
+
ValueError: Empty message.
|
| 92 |
+
RuntimeError: Agent or skill failure.
|
| 93 |
+
"""
|
| 94 |
+
if not message or not message.strip():
|
| 95 |
+
raise ValueError("Message cannot be empty.")
|
| 96 |
+
|
| 97 |
+
message = message.strip()
|
| 98 |
+
logger.info("Orchestrator β %r", message)
|
| 99 |
+
|
| 100 |
+
# ββ Step 1: Intent βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 101 |
+
intent = detect_intent(message)["intent"]
|
| 102 |
+
logger.info("Intent: %s", intent)
|
| 103 |
+
|
| 104 |
+
# ββ Step 2: Extraction βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 105 |
+
extracted = extract_fields(message, intent)
|
| 106 |
+
raw_data = {k: v for k, v in extracted.items() if k != "intent"}
|
| 107 |
+
logger.info("Extracted: %s", raw_data)
|
| 108 |
+
|
| 109 |
+
# ββ Step 3: Validation βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 110 |
+
validated = validate_data(intent, raw_data)
|
| 111 |
+
logger.info("Validated: %s", validated)
|
| 112 |
+
|
| 113 |
+
# ββ Step 4: Skill routing ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 114 |
+
skill_event = route_to_skill(intent, validated)
|
| 115 |
+
logger.info("Skill event: %s", skill_event)
|
| 116 |
+
|
| 117 |
+
return _build_result(message, intent, validated, skill_event)
|
agent/router.py
CHANGED
|
@@ -1,9 +1,83 @@
|
|
| 1 |
"""
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
router.py
|
| 3 |
+
---------
|
| 4 |
+
Stage 5: Skill Router for Notiflow
|
| 5 |
+
|
| 6 |
+
The Skill Router is the decision layer between the Extraction Agent
|
| 7 |
+
and the Business Skills. It receives a structured event (intent + data)
|
| 8 |
+
and dispatches to the correct skill.
|
| 9 |
+
|
| 10 |
+
Routing table:
|
| 11 |
+
|
| 12 |
+
intent β skill function
|
| 13 |
+
βββββββββββββββββββββββββββββββββ
|
| 14 |
+
order β process_order()
|
| 15 |
+
payment β process_payment()
|
| 16 |
+
credit β process_credit()
|
| 17 |
+
return β process_return()
|
| 18 |
+
preparation β process_preparation()
|
| 19 |
+
other β (no skill; passthrough)
|
| 20 |
+
|
| 21 |
+
If an intent has no registered skill the router returns a lightweight
|
| 22 |
+
passthrough event so the pipeline never raises on unknown intents.
|
| 23 |
"""
|
| 24 |
|
| 25 |
+
import logging
|
| 26 |
+
from typing import Any
|
| 27 |
+
|
| 28 |
+
from skills.order_skill import process_order
|
| 29 |
+
from skills.payment_skill import process_payment
|
| 30 |
+
from skills.credit_skill import process_credit
|
| 31 |
+
from skills.return_skill import process_return
|
| 32 |
+
from skills.preparation_skill import process_preparation
|
| 33 |
+
|
| 34 |
+
logger = logging.getLogger(__name__)
|
| 35 |
+
|
| 36 |
+
# ---------------------------------------------------------------------------
|
| 37 |
+
# Routing table (intent β skill callable)
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
|
| 40 |
+
_SKILL_MAP: dict[str, Any] = {
|
| 41 |
+
"order": process_order,
|
| 42 |
+
"payment": process_payment,
|
| 43 |
+
"credit": process_credit,
|
| 44 |
+
"return": process_return,
|
| 45 |
+
"preparation": process_preparation,
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ---------------------------------------------------------------------------
|
| 50 |
+
# Public API
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
|
| 53 |
+
def route_to_skill(intent: str, data: dict) -> dict:
|
| 54 |
+
"""
|
| 55 |
+
Route a structured business event to the appropriate skill.
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
intent: The detected intent string (e.g. "payment", "order").
|
| 59 |
+
data: The extracted field dict returned by the Extraction Agent
|
| 60 |
+
(without the "intent" key β that lives at the top level).
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
A skill event dict. Structure varies per skill but always contains
|
| 64 |
+
at minimum an "event" key describing what happened.
|
| 65 |
+
|
| 66 |
+
For unrecognised / "other" intents a passthrough dict is returned:
|
| 67 |
+
{"event": "unhandled", "intent": intent, "data": data}
|
| 68 |
+
|
| 69 |
+
Example:
|
| 70 |
+
>>> route_to_skill("payment", {"customer": "Rahul", "amount": 15000})
|
| 71 |
+
{
|
| 72 |
+
"event": "payment_recorded",
|
| 73 |
+
"payment": {"customer": "Rahul", "amount": 15000, "status": "received"}
|
| 74 |
+
}
|
| 75 |
+
"""
|
| 76 |
+
skill_fn = _SKILL_MAP.get(intent)
|
| 77 |
+
|
| 78 |
+
if skill_fn is None:
|
| 79 |
+
logger.info("No skill registered for intent '%s' β returning passthrough.", intent)
|
| 80 |
+
return {"event": "unhandled", "intent": intent, "data": data}
|
| 81 |
+
|
| 82 |
+
logger.info("Routing intent '%s' to skill: %s", intent, skill_fn.__name__)
|
| 83 |
+
return skill_fn(data)
|
agent/skill_generator.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
skill_generator.py
|
| 3 |
+
------------------
|
| 4 |
+
Dynamic Skill Generator for Notiflow.
|
| 5 |
+
|
| 6 |
+
Creates new business skill Python files on demand and registers them in
|
| 7 |
+
skills/skill_registry.json.
|
| 8 |
+
|
| 9 |
+
Public API
|
| 10 |
+
----------
|
| 11 |
+
generate_skill(skill_name: str, description: str) -> dict
|
| 12 |
+
list_skills() -> dict
|
| 13 |
+
|
| 14 |
+
Safety rules:
|
| 15 |
+
- Raises SkillAlreadyExistsError if a skill with the same name exists.
|
| 16 |
+
- Skill names are normalised to snake_case.
|
| 17 |
+
- Generated files follow the standard skill template.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
import logging
|
| 24 |
+
import re
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
from typing import Optional
|
| 27 |
+
|
| 28 |
+
from app.config import ROOT, REGISTRY_FILE
|
| 29 |
+
|
| 30 |
+
logger = logging.getLogger(__name__)
|
| 31 |
+
|
| 32 |
+
SKILLS_DIR = ROOT / "skills"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
# Exceptions
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
|
| 39 |
+
class SkillAlreadyExistsError(Exception):
|
| 40 |
+
"""Raised when a skill with the given name already exists."""
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ---------------------------------------------------------------------------
|
| 44 |
+
# Skill file template
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
|
| 47 |
+
_SKILL_TEMPLATE = '''\
|
| 48 |
+
"""
|
| 49 |
+
{skill_name}.py
|
| 50 |
+
{underline}
|
| 51 |
+
Auto-generated business skill for Notiflow.
|
| 52 |
+
|
| 53 |
+
Description: {description}
|
| 54 |
+
|
| 55 |
+
Modify this file to implement the skill logic.
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
from __future__ import annotations
|
| 59 |
+
|
| 60 |
+
import logging
|
| 61 |
+
from datetime import datetime, timezone
|
| 62 |
+
|
| 63 |
+
logger = logging.getLogger(__name__)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def {func_name}(data: dict) -> dict:
|
| 67 |
+
"""
|
| 68 |
+
Execute the {display_name} skill.
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
data: Validated extraction dict from the orchestrator.
|
| 72 |
+
|
| 73 |
+
Returns:
|
| 74 |
+
Structured skill event dict.
|
| 75 |
+
"""
|
| 76 |
+
logger.info("{display_name} skill executing: %s", data)
|
| 77 |
+
|
| 78 |
+
return {{
|
| 79 |
+
"event": "{event_name}",
|
| 80 |
+
"data": data,
|
| 81 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 82 |
+
}}
|
| 83 |
+
'''
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ---------------------------------------------------------------------------
|
| 87 |
+
# Helpers
|
| 88 |
+
# ---------------------------------------------------------------------------
|
| 89 |
+
|
| 90 |
+
def _to_snake_case(name: str) -> str:
|
| 91 |
+
"""Normalise skill name to snake_case (alphanumeric + underscores only)."""
|
| 92 |
+
name = name.strip().lower()
|
| 93 |
+
name = re.sub(r"[^a-z0-9]+", "_", name)
|
| 94 |
+
name = re.sub(r"_+", "_", name).strip("_")
|
| 95 |
+
return name
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _load_registry() -> dict:
|
| 99 |
+
path = Path(REGISTRY_FILE)
|
| 100 |
+
if not path.exists():
|
| 101 |
+
return {}
|
| 102 |
+
try:
|
| 103 |
+
with path.open("r", encoding="utf-8") as f:
|
| 104 |
+
return json.load(f)
|
| 105 |
+
except (json.JSONDecodeError, OSError) as exc:
|
| 106 |
+
logger.warning("Could not read registry: %s", exc)
|
| 107 |
+
return {}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _save_registry(registry: dict) -> None:
|
| 111 |
+
path = Path(REGISTRY_FILE)
|
| 112 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 113 |
+
with path.open("w", encoding="utf-8") as f:
|
| 114 |
+
json.dump(registry, f, indent=2, ensure_ascii=False)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ---------------------------------------------------------------------------
|
| 118 |
+
# Public API
|
| 119 |
+
# ---------------------------------------------------------------------------
|
| 120 |
+
|
| 121 |
+
def generate_skill(skill_name: str, description: str) -> dict:
|
| 122 |
+
"""
|
| 123 |
+
Generate a new skill file and register it.
|
| 124 |
+
|
| 125 |
+
Args:
|
| 126 |
+
skill_name: Human-readable name (e.g. "discount_skill" or "Discount Skill").
|
| 127 |
+
Normalised to snake_case automatically.
|
| 128 |
+
description: One-line description stored in the registry.
|
| 129 |
+
|
| 130 |
+
Returns:
|
| 131 |
+
Registry entry dict for the new skill:
|
| 132 |
+
{
|
| 133 |
+
"description": str,
|
| 134 |
+
"intent": None,
|
| 135 |
+
"file": "skills/<name>.py",
|
| 136 |
+
"builtin": false
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
Raises:
|
| 140 |
+
SkillAlreadyExistsError: If a skill with the same name already exists
|
| 141 |
+
(either as a .py file or registry entry).
|
| 142 |
+
ValueError: If skill_name is empty or invalid.
|
| 143 |
+
|
| 144 |
+
Example:
|
| 145 |
+
>>> generate_skill("discount_skill", "Apply discount to an order")
|
| 146 |
+
{"description": "Apply discount...", "file": "skills/discount_skill.py", ...}
|
| 147 |
+
"""
|
| 148 |
+
norm_name = _to_snake_case(skill_name)
|
| 149 |
+
if not norm_name:
|
| 150 |
+
raise ValueError(f"Invalid skill name: {skill_name!r}")
|
| 151 |
+
|
| 152 |
+
skill_file = SKILLS_DIR / f"{norm_name}.py"
|
| 153 |
+
registry = _load_registry()
|
| 154 |
+
|
| 155 |
+
# ββ Collision guard ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 156 |
+
if norm_name in registry:
|
| 157 |
+
raise SkillAlreadyExistsError(
|
| 158 |
+
f"Skill '{norm_name}' already exists in the registry. "
|
| 159 |
+
"Choose a different name or delete the existing entry first."
|
| 160 |
+
)
|
| 161 |
+
if skill_file.exists():
|
| 162 |
+
raise SkillAlreadyExistsError(
|
| 163 |
+
f"Skill file '{skill_file}' already exists on disk. "
|
| 164 |
+
"Choose a different name or delete the existing file first."
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
# ββ Generate file βββββββοΏ½οΏ½οΏ½ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 168 |
+
display_name = norm_name.replace("_", " ").title()
|
| 169 |
+
func_name = norm_name
|
| 170 |
+
event_name = f"{norm_name}_executed"
|
| 171 |
+
underline = "-" * (len(norm_name) + 3) # matches "name.py" length
|
| 172 |
+
|
| 173 |
+
source = _SKILL_TEMPLATE.format(
|
| 174 |
+
skill_name = norm_name,
|
| 175 |
+
underline = underline,
|
| 176 |
+
description = description,
|
| 177 |
+
func_name = func_name,
|
| 178 |
+
display_name = display_name,
|
| 179 |
+
event_name = event_name,
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
SKILLS_DIR.mkdir(parents=True, exist_ok=True)
|
| 183 |
+
skill_file.write_text(source, encoding="utf-8")
|
| 184 |
+
logger.info("Skill file created: %s", skill_file)
|
| 185 |
+
|
| 186 |
+
# ββ Register βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 187 |
+
entry = {
|
| 188 |
+
"description": description,
|
| 189 |
+
"intent": None, # caller can update after creation
|
| 190 |
+
"file": f"skills/{norm_name}.py",
|
| 191 |
+
"builtin": False,
|
| 192 |
+
}
|
| 193 |
+
registry[norm_name] = entry
|
| 194 |
+
_save_registry(registry)
|
| 195 |
+
logger.info("Skill '%s' registered.", norm_name)
|
| 196 |
+
|
| 197 |
+
return entry
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def list_skills() -> dict:
|
| 201 |
+
"""
|
| 202 |
+
Return the full skill registry.
|
| 203 |
+
|
| 204 |
+
Returns:
|
| 205 |
+
Dict mapping skill_name β registry entry.
|
| 206 |
+
"""
|
| 207 |
+
return _load_registry()
|
app/__pycache__/bedrock_client.cpython-311.pyc
ADDED
|
Binary file (794 Bytes). View file
|
|
|
app/__pycache__/bedrock_client.cpython-312.pyc
ADDED
|
Binary file (3 kB). View file
|
|
|
app/__pycache__/config.cpython-311.pyc
ADDED
|
Binary file (1.54 kB). View file
|
|
|
app/__pycache__/config.cpython-312.pyc
ADDED
|
Binary file (1.18 kB). View file
|
|
|
app/__pycache__/main.cpython-311.pyc
ADDED
|
Binary file (2.05 kB). View file
|
|
|
app/__pycache__/main.cpython-312.pyc
ADDED
|
Binary file (7.4 kB). View file
|
|
|
app/bedrock_client.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
bedrock_client.py
|
| 3 |
+
-----------------
|
| 4 |
+
Reusable Amazon Bedrock runtime client for Notiflow.
|
| 5 |
+
|
| 6 |
+
Both the Intent Agent and Extraction Agent import `call_nova()` from
|
| 7 |
+
here instead of managing their own boto3 sessions. The client is
|
| 8 |
+
created once (lazy singleton) and reused across calls.
|
| 9 |
+
|
| 10 |
+
Public API
|
| 11 |
+
----------
|
| 12 |
+
call_nova(prompt: str, max_tokens: int = 256) -> str
|
| 13 |
+
Send a plain-text prompt to Nova 2 Lite and return the response text.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import logging
|
| 19 |
+
from typing import Optional
|
| 20 |
+
|
| 21 |
+
from app.config import BEDROCK_MODEL_ID, BEDROCK_REGION
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# Lazy singleton
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
|
| 29 |
+
_client = None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _get_client():
|
| 33 |
+
global _client
|
| 34 |
+
if _client is None:
|
| 35 |
+
try:
|
| 36 |
+
import boto3
|
| 37 |
+
_client = boto3.client(
|
| 38 |
+
service_name="bedrock-runtime",
|
| 39 |
+
region_name=BEDROCK_REGION,
|
| 40 |
+
)
|
| 41 |
+
logger.info("Bedrock client initialised (region=%s)", BEDROCK_REGION)
|
| 42 |
+
except Exception as exc:
|
| 43 |
+
raise RuntimeError(
|
| 44 |
+
f"Failed to create Bedrock client: {exc}\n"
|
| 45 |
+
"Check that boto3 is installed and AWS credentials are configured."
|
| 46 |
+
) from exc
|
| 47 |
+
return _client
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
# Public API
|
| 52 |
+
# ---------------------------------------------------------------------------
|
| 53 |
+
|
| 54 |
+
def call_nova(prompt: str, max_tokens: int = 256) -> str:
|
| 55 |
+
"""
|
| 56 |
+
Send a prompt to Amazon Nova 2 Lite via the Bedrock Converse API.
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
prompt: Fully rendered prompt string.
|
| 60 |
+
max_tokens: Maximum tokens to generate (default 256).
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
Raw text response from the model.
|
| 64 |
+
|
| 65 |
+
Raises:
|
| 66 |
+
RuntimeError: If the API call fails.
|
| 67 |
+
"""
|
| 68 |
+
client = _get_client()
|
| 69 |
+
|
| 70 |
+
try:
|
| 71 |
+
response = client.converse(
|
| 72 |
+
modelId=BEDROCK_MODEL_ID,
|
| 73 |
+
messages=[
|
| 74 |
+
{
|
| 75 |
+
"role": "user",
|
| 76 |
+
"content": [{"text": prompt}],
|
| 77 |
+
}
|
| 78 |
+
],
|
| 79 |
+
inferenceConfig={
|
| 80 |
+
"maxTokens": max_tokens,
|
| 81 |
+
"temperature": 0.0,
|
| 82 |
+
"topP": 1.0,
|
| 83 |
+
},
|
| 84 |
+
)
|
| 85 |
+
output_message = response["output"]["message"]
|
| 86 |
+
parts = [
|
| 87 |
+
block["text"]
|
| 88 |
+
for block in output_message["content"]
|
| 89 |
+
if "text" in block
|
| 90 |
+
]
|
| 91 |
+
return " ".join(parts).strip()
|
| 92 |
+
|
| 93 |
+
except Exception as exc:
|
| 94 |
+
logger.error("Bedrock call failed: %s", exc)
|
| 95 |
+
raise RuntimeError(f"Nova API error: {exc}") from exc
|
app/config.py
CHANGED
|
@@ -1,7 +1,47 @@
|
|
| 1 |
"""
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
config.py
|
| 3 |
+
---------
|
| 4 |
+
Central configuration for Notiflow.
|
| 5 |
+
|
| 6 |
+
All file paths, feature flags, and model settings live here.
|
| 7 |
+
Every other module imports from this file β no hardcoded paths elsewhere.
|
| 8 |
"""
|
| 9 |
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
# ---------------------------------------------------------------------------
|
| 13 |
+
# Project root
|
| 14 |
+
# ---------------------------------------------------------------------------
|
| 15 |
+
|
| 16 |
+
ROOT = Path(__file__).parent.parent # notiflow/
|
| 17 |
+
|
| 18 |
+
# ---------------------------------------------------------------------------
|
| 19 |
+
# Data paths
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
|
| 22 |
+
DATA_DIR = ROOT / "data"
|
| 23 |
+
DATA_FILE = DATA_DIR / "notiflow_data.xlsx" # Excel business store
|
| 24 |
+
MEMORY_FILE = DATA_DIR / "agent_memory.json" # Agent memory (recent context)
|
| 25 |
+
REGISTRY_FILE = ROOT / "skills" / "skill_registry.json" # Skill registry
|
| 26 |
+
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
# Feature flags
|
| 29 |
+
# ---------------------------------------------------------------------------
|
| 30 |
+
|
| 31 |
+
# When True the dashboard and main.py simulate the pipeline locally.
|
| 32 |
+
# Set to False (or override via env var) to use real Bedrock inference.
|
| 33 |
+
import os
|
| 34 |
+
DEMO_MODE: bool = os.getenv("NOTIFLOW_DEMO_MODE", "true").lower() != "false"
|
| 35 |
+
|
| 36 |
+
# ---------------------------------------------------------------------------
|
| 37 |
+
# Amazon Bedrock settings
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
|
| 40 |
+
BEDROCK_REGION = os.getenv("AWS_REGION", "us-east-1")
|
| 41 |
+
BEDROCK_MODEL_ID = "amazon.nova-lite-v1:0"
|
| 42 |
+
|
| 43 |
+
# ---------------------------------------------------------------------------
|
| 44 |
+
# Ensure data directory exists at import time
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
|
| 47 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
app/main.py
CHANGED
|
@@ -1,11 +1,204 @@
|
|
| 1 |
"""
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
if __name__ == "__main__":
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
main.py
|
| 3 |
+
-------
|
| 4 |
+
Primary entry point for Notiflow.
|
| 5 |
+
|
| 6 |
+
Exposes run_notiflow(message) for programmatic use (dashboard, API, tests)
|
| 7 |
+
and supports CLI testing directly from the terminal.
|
| 8 |
+
|
| 9 |
+
Public API
|
| 10 |
+
----------
|
| 11 |
+
run_notiflow(message: str) -> dict
|
| 12 |
+
|
| 13 |
+
Runs the full pipeline and returns:
|
| 14 |
+
{
|
| 15 |
+
"message": str,
|
| 16 |
+
"intent": str,
|
| 17 |
+
"data": dict,
|
| 18 |
+
"event": dict,
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
CLI usage
|
| 22 |
+
---------
|
| 23 |
+
python app/main.py "rahul ne 15000 bheja"
|
| 24 |
+
|
| 25 |
+
Prints the result as formatted JSON to stdout.
|
| 26 |
+
|
| 27 |
+
Demo mode
|
| 28 |
+
---------
|
| 29 |
+
Controlled by DEMO_MODE in app/config.py or the environment variable
|
| 30 |
+
NOTIFLOW_DEMO_MODE=false (set to disable demo mode).
|
| 31 |
+
|
| 32 |
+
When DEMO_MODE is True, a local simulation is used so the app works
|
| 33 |
+
without AWS credentials. The dashboard's DEMO_MODE toggle maps to this.
|
| 34 |
"""
|
| 35 |
|
| 36 |
+
from __future__ import annotations
|
| 37 |
+
|
| 38 |
+
import json
|
| 39 |
+
import logging
|
| 40 |
+
import sys
|
| 41 |
+
from typing import Any
|
| 42 |
+
|
| 43 |
+
from app.config import DEMO_MODE
|
| 44 |
+
|
| 45 |
+
logger = logging.getLogger(__name__)
|
| 46 |
+
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
# Demo pipeline (no AWS needed)
|
| 49 |
+
# ---------------------------------------------------------------------------
|
| 50 |
+
|
| 51 |
+
_DEMO_RESPONSES: dict[str, dict] = {
|
| 52 |
+
"rahul ne 15000 bheja": {
|
| 53 |
+
"intent": "payment",
|
| 54 |
+
"data": {"customer": "Rahul", "amount": 15000, "payment_type": None},
|
| 55 |
+
"event": {"event": "payment_recorded",
|
| 56 |
+
"payment": {"customer": "Rahul", "amount": 15000,
|
| 57 |
+
"payment_type": None, "status": "received"}},
|
| 58 |
+
},
|
| 59 |
+
"bhaiya 3 kurti bhej dena": {
|
| 60 |
+
"intent": "order",
|
| 61 |
+
"data": {"customer": None, "item": "kurti", "quantity": 3},
|
| 62 |
+
"event": {"event": "order_received",
|
| 63 |
+
"order": {"customer": None, "item": "kurti",
|
| 64 |
+
"quantity": 3, "status": "pending"},
|
| 65 |
+
"invoice": {"invoice_id": "INV-DEMO-0001", "total_amount": 0.0}},
|
| 66 |
+
},
|
| 67 |
+
"priya ke liye 2 kilo aata bhej dena": {
|
| 68 |
+
"intent": "order",
|
| 69 |
+
"data": {"customer": "Priya", "item": "aata", "quantity": 2},
|
| 70 |
+
"event": {"event": "order_received",
|
| 71 |
+
"order": {"customer": "Priya", "item": "aata",
|
| 72 |
+
"quantity": 2, "status": "pending"},
|
| 73 |
+
"invoice": {"invoice_id": "INV-DEMO-0002", "total_amount": 0.0}},
|
| 74 |
+
},
|
| 75 |
+
"size chota hai exchange karna hai": {
|
| 76 |
+
"intent": "return",
|
| 77 |
+
"data": {"customer": None, "item": None, "reason": "size issue"},
|
| 78 |
+
"event": {"event": "return_requested",
|
| 79 |
+
"return": {"customer": None, "item": None,
|
| 80 |
+
"reason": "size issue", "status": "pending_review"}},
|
| 81 |
+
},
|
| 82 |
+
"udhar me de dijiye": {
|
| 83 |
+
"intent": "credit",
|
| 84 |
+
"data": {"customer": None, "item": None, "quantity": None, "amount": None},
|
| 85 |
+
"event": {"event": "credit_recorded",
|
| 86 |
+
"credit": {"customer": None, "amount": None, "status": "open"}},
|
| 87 |
+
},
|
| 88 |
+
"suresh ko 500 ka maal udhar dena": {
|
| 89 |
+
"intent": "credit",
|
| 90 |
+
"data": {"customer": "Suresh", "item": "goods", "quantity": None, "amount": 500},
|
| 91 |
+
"event": {"event": "credit_recorded",
|
| 92 |
+
"credit": {"customer": "Suresh", "amount": 500, "status": "open"}},
|
| 93 |
+
},
|
| 94 |
+
"3 kurti ka set ready rakhna": {
|
| 95 |
+
"intent": "preparation",
|
| 96 |
+
"data": {"item": "kurti", "quantity": 3},
|
| 97 |
+
"event": {"event": "preparation_queued",
|
| 98 |
+
"preparation": {"item": "kurti", "quantity": 3, "status": "queued"}},
|
| 99 |
+
},
|
| 100 |
+
"amit bhai ka 8000 gpay se aaya": {
|
| 101 |
+
"intent": "payment",
|
| 102 |
+
"data": {"customer": "Amit", "amount": 8000, "payment_type": "upi"},
|
| 103 |
+
"event": {"event": "payment_recorded",
|
| 104 |
+
"payment": {"customer": "Amit", "amount": 8000,
|
| 105 |
+
"payment_type": "upi", "status": "received"}},
|
| 106 |
+
},
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _fallback_intent(message: str) -> str:
|
| 111 |
+
m = message.lower()
|
| 112 |
+
if any(w in m for w in ["bheja", "aaya", "cash", "gpay", "upi", "paytm", "online"]):
|
| 113 |
+
return "payment"
|
| 114 |
+
if any(w in m for w in ["exchange", "wapas", "return", "vapas", "size"]):
|
| 115 |
+
return "return"
|
| 116 |
+
if any(w in m for w in ["udhar", "credit", "baad"]):
|
| 117 |
+
return "credit"
|
| 118 |
+
if any(w in m for w in ["ready", "pack", "rakhna", "taiyar"]):
|
| 119 |
+
return "preparation"
|
| 120 |
+
if any(w in m for w in ["bhej", "dena", "chahiye", "kilo", "piece"]):
|
| 121 |
+
return "order"
|
| 122 |
+
return "other"
|
| 123 |
+
|
| 124 |
|
| 125 |
+
def _run_demo(message: str) -> dict[str, Any]:
|
| 126 |
+
key = message.strip().lower()
|
| 127 |
+
response = _DEMO_RESPONSES.get(key)
|
| 128 |
+
if response is None:
|
| 129 |
+
intent = _fallback_intent(message)
|
| 130 |
+
response = {
|
| 131 |
+
"intent": intent,
|
| 132 |
+
"data": {"note": f"Demo: classified as '{intent}'"},
|
| 133 |
+
"event": {"event": f"{intent}_recorded",
|
| 134 |
+
"note": "Demo fallback β no exact match"},
|
| 135 |
+
}
|
| 136 |
+
return {
|
| 137 |
+
"message": message,
|
| 138 |
+
"intent": response["intent"],
|
| 139 |
+
"data": response["data"],
|
| 140 |
+
"event": response["event"],
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# ---------------------------------------------------------------------------
|
| 145 |
+
# Public API
|
| 146 |
+
# ---------------------------------------------------------------------------
|
| 147 |
+
|
| 148 |
+
def run_notiflow(message: str, demo_mode: bool | None = None) -> dict[str, Any]:
|
| 149 |
+
"""
|
| 150 |
+
Run a business message through the full Notiflow pipeline.
|
| 151 |
+
|
| 152 |
+
This is the single function the dashboard and any external caller
|
| 153 |
+
should use. It never calls agents, skills, or Excel directly.
|
| 154 |
+
|
| 155 |
+
Args:
|
| 156 |
+
message: Raw Hinglish or English business message.
|
| 157 |
+
demo_mode: Override DEMO_MODE from config. If None, uses config value.
|
| 158 |
+
|
| 159 |
+
Returns:
|
| 160 |
+
{
|
| 161 |
+
"message": str,
|
| 162 |
+
"intent": str,
|
| 163 |
+
"data": dict,
|
| 164 |
+
"event": dict,
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
Raises:
|
| 168 |
+
ValueError: Empty message.
|
| 169 |
+
RuntimeError: Pipeline failure (live mode only).
|
| 170 |
+
"""
|
| 171 |
+
if not message or not message.strip():
|
| 172 |
+
raise ValueError("Message cannot be empty.")
|
| 173 |
+
|
| 174 |
+
use_demo = DEMO_MODE if demo_mode is None else demo_mode
|
| 175 |
+
|
| 176 |
+
if use_demo:
|
| 177 |
+
logger.info("run_notiflow [demo] β %r", message)
|
| 178 |
+
return _run_demo(message.strip())
|
| 179 |
+
else:
|
| 180 |
+
logger.info("run_notiflow [live] β %r", message)
|
| 181 |
+
from agent.orchestrator import process_message
|
| 182 |
+
return process_message(message.strip())
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# ---------------------------------------------------------------------------
|
| 186 |
+
# CLI entry point
|
| 187 |
+
# ---------------------------------------------------------------------------
|
| 188 |
|
| 189 |
if __name__ == "__main__":
|
| 190 |
+
logging.basicConfig(level=logging.WARNING)
|
| 191 |
+
|
| 192 |
+
if len(sys.argv) < 2:
|
| 193 |
+
print("Usage: python app/main.py \"<business message>\"")
|
| 194 |
+
print('Example: python app/main.py "rahul ne 15000 bheja"')
|
| 195 |
+
sys.exit(1)
|
| 196 |
+
|
| 197 |
+
input_message = " ".join(sys.argv[1:])
|
| 198 |
+
|
| 199 |
+
try:
|
| 200 |
+
result = run_notiflow(input_message)
|
| 201 |
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
| 202 |
+
except Exception as exc:
|
| 203 |
+
print(json.dumps({"error": str(exc)}, indent=2))
|
| 204 |
+
sys.exit(1)
|
dashboard/streamlit_app.py
CHANGED
|
@@ -1,14 +1,335 @@
|
|
| 1 |
"""
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
import streamlit as st
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
def main():
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
if __name__ == "__main__":
|
| 14 |
-
main()
|
|
|
|
| 1 |
"""
|
| 2 |
+
streamlit_app.py
|
| 3 |
+
----------------
|
| 4 |
+
Stage 7 + FIX 3 + FIX 4: Streamlit Dashboard for Notiflow
|
| 5 |
+
|
| 6 |
+
Changes from Stage 7:
|
| 7 |
+
FIX 3 β Dashboard now calls run_notiflow(message) from app/main.py only.
|
| 8 |
+
No direct imports of agents or orchestrator.
|
| 9 |
+
FIX 4 β File paths and DEMO_MODE come from app/config.py. No hardcoded paths.
|
| 10 |
+
|
| 11 |
+
Run:
|
| 12 |
+
streamlit run dashboard/streamlit_app.py
|
| 13 |
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import sys
|
| 17 |
+
from pathlib import Path
|
| 18 |
|
| 19 |
+
# Add project root to Python path
|
| 20 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 21 |
+
sys.path.append(str(ROOT))
|
| 22 |
+
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
import pandas as pd
|
| 26 |
import streamlit as st
|
| 27 |
|
| 28 |
+
# ββ Page config (must be first Streamlit call) βββββββββββββββββββββββββββββββ
|
| 29 |
+
st.set_page_config(
|
| 30 |
+
page_title="Notiflow Β· AI Operations Dashboard",
|
| 31 |
+
page_icon="β‘",
|
| 32 |
+
layout="wide",
|
| 33 |
+
initial_sidebar_state="expanded",
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
# ββ Config (FIX 4) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 37 |
+
from app.config import DATA_FILE, DEMO_MODE as _CONFIG_DEMO_MODE
|
| 38 |
+
|
| 39 |
+
# ββ Backend entry point (FIX 3) βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
+
from app.main import run_notiflow
|
| 41 |
+
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
# Static data
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
|
| 46 |
+
SAMPLE_MESSAGES = {
|
| 47 |
+
"β pick a sample message β": "",
|
| 48 |
+
"π° Payment β Rahul βΉ15,000": "rahul ne 15000 bheja",
|
| 49 |
+
"π¦ Order β 3 kurties": "bhaiya 3 kurti bhej dena",
|
| 50 |
+
"π¦ Order β Priya, 2 kg atta": "priya ke liye 2 kilo aata bhej dena",
|
| 51 |
+
"π Return β size issue": "size chota hai exchange karna hai",
|
| 52 |
+
"π Credit β simple udhar": "udhar me de dijiye",
|
| 53 |
+
"π Credit β Suresh βΉ500": "suresh ko 500 ka maal udhar dena",
|
| 54 |
+
"ποΈ Prep β pack 3 kurties": "3 kurti ka set ready rakhna",
|
| 55 |
+
"π° Payment β Amit GPay βΉ8,000": "amit bhai ka 8000 gpay se aaya",
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
INTENT_CONFIG = {
|
| 59 |
+
"order": {"emoji": "π¦", "color": "#1E88E5", "label": "Order", "bg": "#E3F2FD"},
|
| 60 |
+
"payment": {"emoji": "π°", "color": "#43A047", "label": "Payment", "bg": "#E8F5E9"},
|
| 61 |
+
"credit": {"emoji": "π", "color": "#FB8C00", "label": "Credit", "bg": "#FFF3E0"},
|
| 62 |
+
"return": {"emoji": "π", "color": "#E53935", "label": "Return", "bg": "#FFEBEE"},
|
| 63 |
+
"preparation": {"emoji": "ποΈ", "color": "#8E24AA", "label": "Preparation","bg": "#F3E5F5"},
|
| 64 |
+
"other": {"emoji": "π¬", "color": "#757575", "label": "Other", "bg": "#F5F5F5"},
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
SHEETS = ["Orders", "Ledger", "Returns", "Inventory", "Invoices"]
|
| 68 |
+
SHEET_ICONS = {"Orders": "π¦", "Ledger": "π°", "Returns": "π",
|
| 69 |
+
"Inventory": "π", "Invoices": "π§Ύ"}
|
| 70 |
+
|
| 71 |
+
# ---------------------------------------------------------------------------
|
| 72 |
+
# Pipeline trace builder
|
| 73 |
+
# Constructs the step-by-step trace from the flat result dict.
|
| 74 |
+
# run_notiflow() returns {message, intent, data, event} β no per-step data
|
| 75 |
+
# in demo mode, so we reconstruct a display trace from the final result.
|
| 76 |
+
# ---------------------------------------------------------------------------
|
| 77 |
+
|
| 78 |
+
def _build_trace(result: dict) -> list[dict]:
|
| 79 |
+
return [
|
| 80 |
+
{
|
| 81 |
+
"icon": "π§ ",
|
| 82 |
+
"step": "Intent Agent",
|
| 83 |
+
"label": f"Intent detected: **{result['intent']}**",
|
| 84 |
+
"note": "Nova 2 Lite reads the Hinglish message and classifies its business intent.",
|
| 85 |
+
"output": {"intent": result["intent"]},
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"icon": "π",
|
| 89 |
+
"step": "Extraction + Validation",
|
| 90 |
+
"label": "Structured fields extracted and validated",
|
| 91 |
+
"note": "Nova 2 Lite extracts entities; validator normalises numbers, text and payment aliases.",
|
| 92 |
+
"output": result["data"],
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
"icon": "βοΈ",
|
| 96 |
+
"step": "Skill Router",
|
| 97 |
+
"label": f"Skill executed: **{result['event'].get('event', '')}**",
|
| 98 |
+
"note": "The router dispatches to the correct business skill, persists data to Excel.",
|
| 99 |
+
"output": result["event"],
|
| 100 |
+
},
|
| 101 |
+
]
|
| 102 |
+
|
| 103 |
+
# ---------------------------------------------------------------------------
|
| 104 |
+
# UI helpers
|
| 105 |
+
# ---------------------------------------------------------------------------
|
| 106 |
+
|
| 107 |
+
def _intent_cfg(intent: str) -> dict:
|
| 108 |
+
return INTENT_CONFIG.get(intent, INTENT_CONFIG["other"])
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _render_intent_badge(intent: str):
|
| 112 |
+
cfg = _intent_cfg(intent)
|
| 113 |
+
st.markdown(
|
| 114 |
+
f"<div style='"
|
| 115 |
+
f"display:inline-block; background:{cfg['bg']};"
|
| 116 |
+
f"border-left:5px solid {cfg['color']}; border-radius:6px;"
|
| 117 |
+
f"padding:8px 18px; font-weight:700; font-size:1.05rem;"
|
| 118 |
+
f"color:{cfg['color']}; margin-bottom:10px; letter-spacing:.04em;"
|
| 119 |
+
f"'>{cfg['emoji']} {cfg['label'].upper()}</div>",
|
| 120 |
+
unsafe_allow_html=True,
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _render_trace(trace: list[dict]):
|
| 125 |
+
st.markdown("#### π Pipeline Trace")
|
| 126 |
+
for i, step in enumerate(trace):
|
| 127 |
+
with st.expander(f"{step['icon']} Step {i+1} β {step['step']}", expanded=True):
|
| 128 |
+
st.markdown(step["label"])
|
| 129 |
+
st.caption(step["note"])
|
| 130 |
+
st.json(step["output"], expanded=False)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _render_result(result: dict):
|
| 134 |
+
intent = result.get("intent", "other")
|
| 135 |
+
st.markdown("#### π Business Event")
|
| 136 |
+
_render_intent_badge(intent)
|
| 137 |
+
|
| 138 |
+
col1, col2 = st.columns(2, gap="medium")
|
| 139 |
+
with col1:
|
| 140 |
+
st.markdown("**Extracted Fields**")
|
| 141 |
+
data = result.get("data", {})
|
| 142 |
+
if data:
|
| 143 |
+
rows = [{"Field": k, "Value": str(v) if v is not None else "β"}
|
| 144 |
+
for k, v in data.items()]
|
| 145 |
+
st.dataframe(pd.DataFrame(rows), use_container_width=True, hide_index=True)
|
| 146 |
+
else:
|
| 147 |
+
st.info("No fields extracted.")
|
| 148 |
+
with col2:
|
| 149 |
+
st.markdown("**Skill Output**")
|
| 150 |
+
st.json(result.get("event", {}), expanded=True)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _render_table(sheet_name: str):
|
| 154 |
+
"""Read sheet from Excel (FIX 4: path from config) and display it."""
|
| 155 |
+
if not Path(DATA_FILE).exists():
|
| 156 |
+
st.info(f"No data file found yet at `{DATA_FILE}`. Process a message to create it.", icon="π")
|
| 157 |
+
return
|
| 158 |
+
try:
|
| 159 |
+
df = pd.read_excel(DATA_FILE, sheet_name=sheet_name)
|
| 160 |
+
except Exception:
|
| 161 |
+
df = pd.DataFrame()
|
| 162 |
+
|
| 163 |
+
if df.empty:
|
| 164 |
+
st.info(f"No records yet in **{sheet_name}**. Process a message to populate this sheet.", icon="π")
|
| 165 |
+
return
|
| 166 |
+
|
| 167 |
+
st.dataframe(df, use_container_width=True, hide_index=True)
|
| 168 |
+
icon = SHEET_ICONS.get(sheet_name, "π")
|
| 169 |
+
st.caption(f"{icon} {len(df)} record{'s' if len(df) != 1 else ''} in **{sheet_name}**")
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _render_sidebar() -> tuple[str, bool]:
|
| 173 |
+
with st.sidebar:
|
| 174 |
+
st.markdown(
|
| 175 |
+
"<div style='text-align:center; padding:10px 0 4px'>"
|
| 176 |
+
"<span style='font-size:2.4rem'>β‘</span><br>"
|
| 177 |
+
"<span style='font-size:1.2rem; font-weight:700'>Notiflow</span><br>"
|
| 178 |
+
"<span style='font-size:.78rem; color:#888'>AI Operations Assistant</span>"
|
| 179 |
+
"</div>",
|
| 180 |
+
unsafe_allow_html=True,
|
| 181 |
+
)
|
| 182 |
+
st.divider()
|
| 183 |
+
|
| 184 |
+
# Demo toggle β default from config (FIX 4)
|
| 185 |
+
demo_mode = st.toggle(
|
| 186 |
+
"π§ͺ Demo Mode",
|
| 187 |
+
value=st.session_state.get("demo_mode", _CONFIG_DEMO_MODE),
|
| 188 |
+
help=(
|
| 189 |
+
"ON β pipeline simulated locally, no AWS needed.\n"
|
| 190 |
+
"OFF β calls Amazon Nova 2 Lite via Bedrock (AWS creds required)."
|
| 191 |
+
),
|
| 192 |
+
)
|
| 193 |
+
if demo_mode:
|
| 194 |
+
st.success("Demo mode active β no AWS needed", icon="β
")
|
| 195 |
+
else:
|
| 196 |
+
st.warning("Live mode β AWS credentials required", icon="β οΈ")
|
| 197 |
+
|
| 198 |
+
st.divider()
|
| 199 |
+
st.markdown("**π Business Data**")
|
| 200 |
+
raw_choice = st.radio(
|
| 201 |
+
"sheet",
|
| 202 |
+
[f"{SHEET_ICONS[s]} {s}" for s in SHEETS],
|
| 203 |
+
label_visibility="collapsed",
|
| 204 |
+
)
|
| 205 |
+
active_sheet = raw_choice.split(" ", 1)[1]
|
| 206 |
+
|
| 207 |
+
st.divider()
|
| 208 |
+
st.markdown(
|
| 209 |
+
"<div style='font-size:.78rem; color:#999'>"
|
| 210 |
+
"Built for the <b>Amazon Nova AI Hackathon</b>.<br>"
|
| 211 |
+
"Powered by <b>Amazon Nova 2 Lite</b> via Bedrock."
|
| 212 |
+
"</div>",
|
| 213 |
+
unsafe_allow_html=True,
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
return active_sheet, demo_mode
|
| 217 |
+
|
| 218 |
+
# ---------------------------------------------------------------------------
|
| 219 |
+
# Main
|
| 220 |
+
# ---------------------------------------------------------------------------
|
| 221 |
|
| 222 |
def main():
|
| 223 |
+
# Session state defaults
|
| 224 |
+
for key, default in {
|
| 225 |
+
"demo_mode": _CONFIG_DEMO_MODE,
|
| 226 |
+
"last_result": None,
|
| 227 |
+
"last_trace": None,
|
| 228 |
+
"last_error": None,
|
| 229 |
+
"msg_input": "",
|
| 230 |
+
}.items():
|
| 231 |
+
if key not in st.session_state:
|
| 232 |
+
st.session_state[key] = default
|
| 233 |
+
|
| 234 |
+
active_sheet, st.session_state.demo_mode = _render_sidebar()
|
| 235 |
+
|
| 236 |
+
# Header
|
| 237 |
+
st.markdown(
|
| 238 |
+
"<h1 style='margin-bottom:2px'>β‘ Notiflow "
|
| 239 |
+
"<span style='font-size:1rem; font-weight:400; color:#888'>"
|
| 240 |
+
"AI Operations Dashboard</span></h1>"
|
| 241 |
+
"<p style='color:#999; margin:0'>"
|
| 242 |
+
"Convert informal Hinglish business messages into structured operations "
|
| 243 |
+
"β powered by Amazon Nova 2 Lite</p>",
|
| 244 |
+
unsafe_allow_html=True,
|
| 245 |
+
)
|
| 246 |
+
st.divider()
|
| 247 |
+
|
| 248 |
+
left, right = st.columns([1, 1], gap="large")
|
| 249 |
+
|
| 250 |
+
# ββ Left β input βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 251 |
+
with left:
|
| 252 |
+
st.markdown("### π¬ Enter Business Message")
|
| 253 |
+
|
| 254 |
+
sample_key = st.selectbox(
|
| 255 |
+
"Quick samples",
|
| 256 |
+
options=list(SAMPLE_MESSAGES.keys()),
|
| 257 |
+
index=0,
|
| 258 |
+
label_visibility="collapsed",
|
| 259 |
+
)
|
| 260 |
+
if SAMPLE_MESSAGES.get(sample_key):
|
| 261 |
+
st.session_state.msg_input = SAMPLE_MESSAGES[sample_key]
|
| 262 |
+
|
| 263 |
+
message = st.text_area(
|
| 264 |
+
"Message",
|
| 265 |
+
value=st.session_state.msg_input,
|
| 266 |
+
height=110,
|
| 267 |
+
placeholder='e.g. "rahul ne 15000 bheja"',
|
| 268 |
+
label_visibility="collapsed",
|
| 269 |
+
)
|
| 270 |
+
st.session_state.msg_input = message
|
| 271 |
+
|
| 272 |
+
btn_label = (
|
| 273 |
+
"π§ͺ Run Demo Pipeline"
|
| 274 |
+
if st.session_state.demo_mode
|
| 275 |
+
else "π Run AI Pipeline (Nova)"
|
| 276 |
+
)
|
| 277 |
+
clicked = st.button(
|
| 278 |
+
btn_label,
|
| 279 |
+
type="primary",
|
| 280 |
+
use_container_width=True,
|
| 281 |
+
disabled=not message.strip(),
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
if clicked and message.strip():
|
| 285 |
+
st.session_state.last_result = None
|
| 286 |
+
st.session_state.last_trace = None
|
| 287 |
+
st.session_state.last_error = None
|
| 288 |
+
|
| 289 |
+
with st.spinner("Running agent pipelineβ¦"):
|
| 290 |
+
try:
|
| 291 |
+
# FIX 3: only call run_notiflow β no direct agent imports
|
| 292 |
+
result = run_notiflow(
|
| 293 |
+
message.strip(),
|
| 294 |
+
demo_mode=st.session_state.demo_mode,
|
| 295 |
+
)
|
| 296 |
+
st.session_state.last_result = result
|
| 297 |
+
st.session_state.last_trace = _build_trace(result)
|
| 298 |
+
except Exception as exc:
|
| 299 |
+
st.session_state.last_error = str(exc)
|
| 300 |
+
|
| 301 |
+
if st.session_state.last_error:
|
| 302 |
+
st.error(
|
| 303 |
+
f"**Pipeline error:** {st.session_state.last_error}\n\n"
|
| 304 |
+
"Tip: Enable **Demo Mode** in the sidebar to run without AWS credentials.",
|
| 305 |
+
icon="π¨",
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
# ββ Right β output ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 309 |
+
with right:
|
| 310 |
+
if st.session_state.last_result and st.session_state.last_trace:
|
| 311 |
+
_render_trace(st.session_state.last_trace)
|
| 312 |
+
st.divider()
|
| 313 |
+
_render_result(st.session_state.last_result)
|
| 314 |
+
else:
|
| 315 |
+
st.markdown("### π Agent Output")
|
| 316 |
+
st.markdown(
|
| 317 |
+
"<div style='background:#F8F9FA; border-radius:12px;"
|
| 318 |
+
"padding:52px 30px; text-align:center; color:#aaa;'>"
|
| 319 |
+
"<div style='font-size:3rem'>β‘</div>"
|
| 320 |
+
"<div style='margin-top:10px; font-size:.95rem; line-height:1.6'>"
|
| 321 |
+
"Select a sample or type a message,<br>"
|
| 322 |
+
"then click <strong>Run Pipeline</strong>."
|
| 323 |
+
"</div></div>",
|
| 324 |
+
unsafe_allow_html=True,
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
# ββ Data tables βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 328 |
+
st.divider()
|
| 329 |
+
icon = SHEET_ICONS.get(active_sheet, "π")
|
| 330 |
+
st.markdown(f"### {icon} {active_sheet}")
|
| 331 |
+
_render_table(active_sheet)
|
| 332 |
|
| 333 |
|
| 334 |
if __name__ == "__main__":
|
| 335 |
+
main()
|
memory/agent_memory.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
agent_memory.py
|
| 3 |
+
---------------
|
| 4 |
+
Agent memory layer for Notiflow.
|
| 5 |
+
|
| 6 |
+
Stores recent business context so skills and future agents can reference
|
| 7 |
+
what was last discussed (customer names, items, etc.).
|
| 8 |
+
|
| 9 |
+
Storage: JSON file at the path defined in app/config.py (MEMORY_FILE).
|
| 10 |
+
Structure:
|
| 11 |
+
{
|
| 12 |
+
"recent_customers": ["Rahul", "Priya"], # newest last
|
| 13 |
+
"recent_items": ["kurti", "aata"]
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
Public API
|
| 17 |
+
----------
|
| 18 |
+
load_memory() -> dict
|
| 19 |
+
update_memory(customer=None, item=None) -> None
|
| 20 |
+
|
| 21 |
+
Design notes:
|
| 22 |
+
- Maximum 10 entries per list (oldest pruned automatically).
|
| 23 |
+
- Read-modify-write is done in one function call to minimise race window.
|
| 24 |
+
- None values are silently ignored (no-op).
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import json
|
| 30 |
+
import logging
|
| 31 |
+
from pathlib import Path
|
| 32 |
+
from typing import Optional
|
| 33 |
+
|
| 34 |
+
from app.config import MEMORY_FILE
|
| 35 |
+
|
| 36 |
+
logger = logging.getLogger(__name__)
|
| 37 |
+
|
| 38 |
+
_MAX_ENTRIES = 10
|
| 39 |
+
|
| 40 |
+
_EMPTY_MEMORY: dict = {
|
| 41 |
+
"recent_customers": [],
|
| 42 |
+
"recent_items": [],
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
# Internal helpers
|
| 48 |
+
# ---------------------------------------------------------------------------
|
| 49 |
+
|
| 50 |
+
def _read_file() -> dict:
|
| 51 |
+
"""Read memory from disk; return empty structure if file missing/corrupt."""
|
| 52 |
+
path = Path(MEMORY_FILE)
|
| 53 |
+
if not path.exists():
|
| 54 |
+
return {k: list(v) for k, v in _EMPTY_MEMORY.items()}
|
| 55 |
+
try:
|
| 56 |
+
with path.open("r", encoding="utf-8") as f:
|
| 57 |
+
data = json.load(f)
|
| 58 |
+
# Ensure both keys are present even if file is partial
|
| 59 |
+
data.setdefault("recent_customers", [])
|
| 60 |
+
data.setdefault("recent_items", [])
|
| 61 |
+
return data
|
| 62 |
+
except (json.JSONDecodeError, OSError) as exc:
|
| 63 |
+
logger.warning("Could not read memory file (%s) β using empty memory.", exc)
|
| 64 |
+
return {k: list(v) for k, v in _EMPTY_MEMORY.items()}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _write_file(memory: dict) -> None:
|
| 68 |
+
"""Write memory dict to disk atomically (write to temp then rename)."""
|
| 69 |
+
path = Path(MEMORY_FILE)
|
| 70 |
+
tmp = path.with_suffix(".tmp")
|
| 71 |
+
try:
|
| 72 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 73 |
+
with tmp.open("w", encoding="utf-8") as f:
|
| 74 |
+
json.dump(memory, f, indent=2, ensure_ascii=False)
|
| 75 |
+
tmp.replace(path)
|
| 76 |
+
except OSError as exc:
|
| 77 |
+
logger.error("Could not write memory file: %s", exc)
|
| 78 |
+
if tmp.exists():
|
| 79 |
+
tmp.unlink(missing_ok=True)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _append_unique(lst: list, value: str, max_size: int = _MAX_ENTRIES) -> list:
|
| 83 |
+
"""
|
| 84 |
+
Append value to list, deduplicate, and keep only the most recent entries.
|
| 85 |
+
Most recent item is always at the end.
|
| 86 |
+
"""
|
| 87 |
+
if value in lst:
|
| 88 |
+
lst.remove(value) # remove old occurrence so it moves to end
|
| 89 |
+
lst.append(value)
|
| 90 |
+
return lst[-max_size:] # keep newest max_size entries
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ---------------------------------------------------------------------------
|
| 94 |
+
# Public API
|
| 95 |
+
# ---------------------------------------------------------------------------
|
| 96 |
+
|
| 97 |
+
def load_memory() -> dict:
|
| 98 |
+
"""
|
| 99 |
+
Load the current agent memory from disk.
|
| 100 |
+
|
| 101 |
+
Returns:
|
| 102 |
+
{
|
| 103 |
+
"recent_customers": [str, ...],
|
| 104 |
+
"recent_items": [str, ...]
|
| 105 |
+
}
|
| 106 |
+
"""
|
| 107 |
+
memory = _read_file()
|
| 108 |
+
logger.debug("Memory loaded: %s", memory)
|
| 109 |
+
return memory
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def update_memory(
|
| 113 |
+
customer: Optional[str] = None,
|
| 114 |
+
item: Optional[str] = None,
|
| 115 |
+
) -> None:
|
| 116 |
+
"""
|
| 117 |
+
Update agent memory with a new customer name and/or item.
|
| 118 |
+
|
| 119 |
+
None values are silently ignored.
|
| 120 |
+
Duplicates are deduplicated and moved to the end (most recent position).
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
customer: Customer name to remember (e.g. "Rahul").
|
| 124 |
+
item: Item name to remember (e.g. "kurti").
|
| 125 |
+
|
| 126 |
+
Example:
|
| 127 |
+
>>> update_memory(customer="Rahul", item="kurti")
|
| 128 |
+
"""
|
| 129 |
+
if customer is None and item is None:
|
| 130 |
+
return
|
| 131 |
+
|
| 132 |
+
memory = _read_file()
|
| 133 |
+
|
| 134 |
+
if customer:
|
| 135 |
+
memory["recent_customers"] = _append_unique(
|
| 136 |
+
memory["recent_customers"], str(customer).strip()
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
if item:
|
| 140 |
+
memory["recent_items"] = _append_unique(
|
| 141 |
+
memory["recent_items"], str(item).strip()
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
_write_file(memory)
|
| 145 |
+
logger.info("Memory updated: customer=%s item=%s", customer, item)
|
parsers/__pycache__/message_parser.cpython-311.pyc
ADDED
|
Binary file (1.51 kB). View file
|
|
|
parsers/message_parser.py
CHANGED
|
@@ -1,9 +1,26 @@
|
|
| 1 |
"""
|
| 2 |
-
Message
|
| 3 |
"""
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
class MessageParser:
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
Message normalization helpers for Notiflow.
|
| 3 |
"""
|
| 4 |
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import re
|
| 8 |
+
|
| 9 |
+
|
| 10 |
class MessageParser:
|
| 11 |
+
"""Normalize incoming business messages before they reach the agents."""
|
| 12 |
+
|
| 13 |
+
_whitespace_pattern = re.compile(r"\s+")
|
| 14 |
+
|
| 15 |
+
def parse(self, message: str) -> str:
|
| 16 |
+
"""Normalize a message for downstream LLM processing."""
|
| 17 |
+
if message is None:
|
| 18 |
+
return ""
|
| 19 |
+
normalized = str(message).strip().lower()
|
| 20 |
+
normalized = self._whitespace_pattern.sub(" ", normalized)
|
| 21 |
+
return normalized
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def parse_message(message: str) -> str:
|
| 25 |
+
"""Convenience wrapper used by the backend entry point."""
|
| 26 |
+
return MessageParser().parse(message)
|
prompts/extraction_prompt.txt
CHANGED
|
@@ -1,9 +1,122 @@
|
|
| 1 |
-
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You are an AI data extraction agent for Notiflow, a business operations assistant for small businesses in India.
|
| 2 |
|
| 3 |
+
Your job is to extract structured business information from a message written in Hinglish (a mix of Hindi and English, often informal or colloquial).
|
| 4 |
|
| 5 |
+
You will be given:
|
| 6 |
+
1. A business message
|
| 7 |
+
2. The already-detected intent of the message
|
| 8 |
+
|
| 9 |
+
## Your Task
|
| 10 |
+
|
| 11 |
+
Extract all relevant business fields from the message based on the intent.
|
| 12 |
+
Return ONLY a valid JSON object. No explanation. No markdown. No extra text.
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## Supported Intents and Their Fields
|
| 17 |
+
|
| 18 |
+
### order
|
| 19 |
+
Extract:
|
| 20 |
+
- customer (string | null): Name of the person placing the order, if mentioned
|
| 21 |
+
- item (string | null): Product being ordered (translate/normalize to English)
|
| 22 |
+
- quantity (number | null): How many units, kilos, sets, pieces, etc.
|
| 23 |
+
|
| 24 |
+
### payment
|
| 25 |
+
Extract:
|
| 26 |
+
- customer (string | null): Name of the person who sent/received money
|
| 27 |
+
- amount (number | null): The monetary amount (digits only, no currency symbol)
|
| 28 |
+
- payment_type (string | null): Mode of payment if mentioned β "cash", "upi", "online", "cheque", or null
|
| 29 |
+
|
| 30 |
+
### credit
|
| 31 |
+
Extract:
|
| 32 |
+
- customer (string | null): Name of the person taking goods on credit
|
| 33 |
+
- item (string | null): Product being taken on credit, if mentioned
|
| 34 |
+
- quantity (number | null): Quantity, if mentioned
|
| 35 |
+
- amount (number | null): Credit amount if specified
|
| 36 |
+
|
| 37 |
+
### return
|
| 38 |
+
Extract:
|
| 39 |
+
- customer (string | null): Name of the person returning the item, if mentioned
|
| 40 |
+
- item (string | null): Product being returned or exchanged, if mentioned
|
| 41 |
+
- reason (string | null): Reason for return β e.g. "size issue", "damaged", "wrong item"
|
| 42 |
+
|
| 43 |
+
### preparation
|
| 44 |
+
Extract:
|
| 45 |
+
- item (string | null): Product to be prepared or packed
|
| 46 |
+
- quantity (number | null): How many units to prepare
|
| 47 |
+
|
| 48 |
+
### other
|
| 49 |
+
Extract:
|
| 50 |
+
- note (string | null): A short English summary of what the message says
|
| 51 |
+
|
| 52 |
+
---
|
| 53 |
+
|
| 54 |
+
## Hinglish Business Vocabulary Reference
|
| 55 |
+
|
| 56 |
+
- "bhej dena" = send/deliver β order
|
| 57 |
+
- "bheja" = sent (money) β payment
|
| 58 |
+
- "ne bheja" = "[person] sent" β customer sent payment
|
| 59 |
+
- "exchange karna" / "wapas karna" / "return karna" = return/exchange
|
| 60 |
+
- "udhar" / "udhaar" = on credit
|
| 61 |
+
- "ready rakhna" / "pack karna" = prepare/pack
|
| 62 |
+
- "kilo", "kg" = kilogram quantity
|
| 63 |
+
- "piece", "pcs", "nag" = unit quantity
|
| 64 |
+
- "set" = a set/bundle
|
| 65 |
+
- "kurti", "suit", "saree", "maal", "kapda" = clothing/fabric items
|
| 66 |
+
- "chota" = small (size issue), "bada" = large
|
| 67 |
+
- "number" can mean size (shoe/garment size)
|
| 68 |
+
- "clear ho gaya" = payment cleared
|
| 69 |
+
- "UPI", "paytm", "gpay", "phonepay", "online", "cash" = payment types
|
| 70 |
+
|
| 71 |
+
---
|
| 72 |
+
|
| 73 |
+
## Rules
|
| 74 |
+
|
| 75 |
+
1. Always return a JSON object. Never return plain text.
|
| 76 |
+
2. Always include the "intent" field using the intent you were given.
|
| 77 |
+
3. If a field is not present in the message, set it to null.
|
| 78 |
+
4. Never guess or hallucinate values not present in the message.
|
| 79 |
+
5. Normalize names to Title Case (e.g., "rahul" β "Rahul").
|
| 80 |
+
6. Normalize items to simple English nouns (e.g., "kurti" β "kurti", "maal" β "goods").
|
| 81 |
+
7. Extract numbers as integers or floats, not strings.
|
| 82 |
+
8. Do not include any field not listed for the given intent.
|
| 83 |
+
|
| 84 |
+
---
|
| 85 |
+
|
| 86 |
+
## Examples
|
| 87 |
+
|
| 88 |
+
Intent: payment
|
| 89 |
+
Message: "rahul ne 15000 bheja"
|
| 90 |
+
Output: {"intent": "payment", "customer": "Rahul", "amount": 15000, "payment_type": null}
|
| 91 |
+
|
| 92 |
+
Intent: order
|
| 93 |
+
Message: "bhaiya 3 kurti bhej dena"
|
| 94 |
+
Output: {"intent": "order", "customer": null, "item": "kurti", "quantity": 3}
|
| 95 |
+
|
| 96 |
+
Intent: order
|
| 97 |
+
Message: "priya ke liye 2 kilo aata bhej dena"
|
| 98 |
+
Output: {"intent": "order", "customer": "Priya", "item": "aata", "quantity": 2}
|
| 99 |
+
|
| 100 |
+
Intent: return
|
| 101 |
+
Message: "size chota hai exchange karna hai"
|
| 102 |
+
Output: {"intent": "return", "customer": null, "item": null, "reason": "size issue"}
|
| 103 |
+
|
| 104 |
+
Intent: credit
|
| 105 |
+
Message: "suresh ko udhar me 500 ka maal dena"
|
| 106 |
+
Output: {"intent": "credit", "customer": "Suresh", "item": "goods", "quantity": null, "amount": 500}
|
| 107 |
+
|
| 108 |
+
Intent: preparation
|
| 109 |
+
Message: "3 kurti ka set ready rakhna"
|
| 110 |
+
Output: {"intent": "preparation", "item": "kurti", "quantity": 3}
|
| 111 |
+
|
| 112 |
+
Intent: payment
|
| 113 |
+
Message: "amit bhai ka 8000 gpay se aaya"
|
| 114 |
+
Output: {"intent": "payment", "customer": "Amit", "amount": 8000, "payment_type": "upi"}
|
| 115 |
+
|
| 116 |
+
---
|
| 117 |
+
|
| 118 |
+
Now extract fields from the following:
|
| 119 |
+
|
| 120 |
+
Intent: {intent}
|
| 121 |
+
Message: "{message}"
|
| 122 |
+
Output:
|
prompts/intent_prompt.txt
CHANGED
|
@@ -1,8 +1,64 @@
|
|
| 1 |
-
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You are an AI agent for Notiflow, a business assistant for small businesses in India.
|
| 2 |
|
| 3 |
+
Your job is to read a business message written in Hinglish (a mix of Hindi and English, often informal or colloquial) and classify its business intent.
|
| 4 |
|
| 5 |
+
## Supported Intents
|
| 6 |
+
|
| 7 |
+
| Intent | Description |
|
| 8 |
+
|-------------|-----------------------------------------------------------------------------|
|
| 9 |
+
| order | Customer wants to buy or order a product |
|
| 10 |
+
| payment | Money has been received or sent by a customer or party |
|
| 11 |
+
| credit | Customer wants goods on credit (udhar), or an udhar transaction is recorded |
|
| 12 |
+
| return | Customer wants to exchange or return a product |
|
| 13 |
+
| preparation | Shop owner needs to prepare, pack, or keep a set of items ready |
|
| 14 |
+
| other | Message does not match any known business intent |
|
| 15 |
+
|
| 16 |
+
## Hinglish Business Vocabulary Reference
|
| 17 |
+
|
| 18 |
+
- "bhej dena" = send it / deliver it β likely an order
|
| 19 |
+
- "bheja" = sent (money) β likely a payment
|
| 20 |
+
- "exchange karna" / "wapas karna" = return/exchange β return
|
| 21 |
+
- "udhar" / "udhaar" = on credit β credit
|
| 22 |
+
- "ready rakhna" / "pack karna" = prepare/pack β preparation
|
| 23 |
+
- "kilo", "piece", "set", "number" = quantity markers often found in orders
|
| 24 |
+
- Names followed by amounts (e.g., "Rahul ne 500 bheja") β payment
|
| 25 |
+
- "chota", "bada", "size" complaints β return
|
| 26 |
+
|
| 27 |
+
## Rules
|
| 28 |
+
|
| 29 |
+
1. Read the message carefully.
|
| 30 |
+
2. Consider the full context, not just individual words.
|
| 31 |
+
3. Always respond with a single JSON object and nothing else.
|
| 32 |
+
4. Do not include any explanation, commentary, or markdown formatting.
|
| 33 |
+
5. The JSON must have exactly one key: "intent".
|
| 34 |
+
|
| 35 |
+
## Output Format
|
| 36 |
+
|
| 37 |
+
{"intent": "<one of: order, payment, credit, return, preparation, other>"}
|
| 38 |
+
|
| 39 |
+
## Examples
|
| 40 |
+
|
| 41 |
+
Message: "bhaiya 2 kilo bhej dena"
|
| 42 |
+
Output: {"intent": "order"}
|
| 43 |
+
|
| 44 |
+
Message: "rahul ne 15000 bheja"
|
| 45 |
+
Output: {"intent": "payment"}
|
| 46 |
+
|
| 47 |
+
Message: "size chota hai exchange karna hai"
|
| 48 |
+
Output: {"intent": "return"}
|
| 49 |
+
|
| 50 |
+
Message: "udhar me de dijiye"
|
| 51 |
+
Output: {"intent": "credit"}
|
| 52 |
+
|
| 53 |
+
Message: "3 kurti ka set ready rakhna"
|
| 54 |
+
Output: {"intent": "preparation"}
|
| 55 |
+
|
| 56 |
+
Message: "aaj mausam bahut achha hai"
|
| 57 |
+
Output: {"intent": "other"}
|
| 58 |
+
|
| 59 |
+
---
|
| 60 |
+
|
| 61 |
+
Now classify the following message:
|
| 62 |
+
|
| 63 |
+
Message: "{message}"
|
| 64 |
+
Output:
|
services/inventory_service.py
CHANGED
|
@@ -1,9 +1,146 @@
|
|
| 1 |
"""
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
inventory_service.py
|
| 3 |
+
--------------------
|
| 4 |
+
Stage 6: Inventory Service for Notiflow
|
| 5 |
+
|
| 6 |
+
Tracks stock movements as a delta log in the Inventory Excel sheet.
|
| 7 |
+
Each event appends one row recording what changed, by how much,
|
| 8 |
+
and in which direction (in / out).
|
| 9 |
+
|
| 10 |
+
Design: delta-log (not current-stock snapshot)
|
| 11 |
+
- Every inventory change is a new row
|
| 12 |
+
- Current stock for an item = sum of all deltas for that item
|
| 13 |
+
- This keeps the history intact and avoids row-update complexity
|
| 14 |
+
|
| 15 |
+
Directions:
|
| 16 |
+
"out" β stock leaves (order fulfilled)
|
| 17 |
+
"in" β stock arrives (return accepted, restock)
|
| 18 |
"""
|
| 19 |
|
| 20 |
+
import logging
|
| 21 |
+
from datetime import datetime, timezone
|
| 22 |
+
|
| 23 |
+
from utils.excel_writer import append_row, read_sheet
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
# Internal helpers
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
|
| 32 |
+
def _now_iso() -> str:
|
| 33 |
+
return datetime.now(timezone.utc).isoformat()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ---------------------------------------------------------------------------
|
| 37 |
+
# Public API
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
|
| 40 |
+
def deduct_stock(item: str, quantity: int | float, reference_id: str, note: str = "") -> dict:
|
| 41 |
+
"""
|
| 42 |
+
Record a stock deduction (items going out β e.g. an order is fulfilled).
|
| 43 |
+
|
| 44 |
+
Args:
|
| 45 |
+
item: Name of the inventory item.
|
| 46 |
+
quantity: Number of units being deducted.
|
| 47 |
+
reference_id: ID of the triggering record (e.g. order_id, invoice_id).
|
| 48 |
+
note: Optional human-readable note.
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
The inventory movement record that was persisted.
|
| 52 |
+
|
| 53 |
+
Example:
|
| 54 |
+
>>> deduct_stock("kurti", 3, "ORD-20240115-0001", "order fulfilled")
|
| 55 |
+
{
|
| 56 |
+
"timestamp": "...",
|
| 57 |
+
"item": "kurti",
|
| 58 |
+
"change": 3,
|
| 59 |
+
"direction": "out",
|
| 60 |
+
"reference_id": "ORD-20240115-0001",
|
| 61 |
+
"note": "order fulfilled"
|
| 62 |
+
}
|
| 63 |
+
"""
|
| 64 |
+
if quantity is None or quantity <= 0:
|
| 65 |
+
logger.warning("deduct_stock called with invalid quantity: %s", quantity)
|
| 66 |
+
return {}
|
| 67 |
+
|
| 68 |
+
record = {
|
| 69 |
+
"timestamp": _now_iso(),
|
| 70 |
+
"item": item,
|
| 71 |
+
"change": quantity,
|
| 72 |
+
"direction": "out",
|
| 73 |
+
"reference_id": reference_id,
|
| 74 |
+
"note": note or "stock deducted",
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
append_row("Inventory", record)
|
| 78 |
+
logger.info("Stock deducted: %s Γ %s (ref: %s)", quantity, item, reference_id)
|
| 79 |
+
return record
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def add_stock(item: str, quantity: int | float, reference_id: str, note: str = "") -> dict:
|
| 83 |
+
"""
|
| 84 |
+
Record a stock addition (items coming in β e.g. a return is accepted).
|
| 85 |
+
|
| 86 |
+
Args:
|
| 87 |
+
item: Name of the inventory item.
|
| 88 |
+
quantity: Number of units being added.
|
| 89 |
+
reference_id: ID of the triggering record (e.g. return_id).
|
| 90 |
+
note: Optional human-readable note.
|
| 91 |
+
|
| 92 |
+
Returns:
|
| 93 |
+
The inventory movement record that was persisted.
|
| 94 |
+
"""
|
| 95 |
+
if quantity is None or quantity <= 0:
|
| 96 |
+
logger.warning("add_stock called with invalid quantity: %s", quantity)
|
| 97 |
+
return {}
|
| 98 |
+
|
| 99 |
+
record = {
|
| 100 |
+
"timestamp": _now_iso(),
|
| 101 |
+
"item": item,
|
| 102 |
+
"change": quantity,
|
| 103 |
+
"direction": "in",
|
| 104 |
+
"reference_id": reference_id,
|
| 105 |
+
"note": note or "stock added",
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
append_row("Inventory", record)
|
| 109 |
+
logger.info("Stock added: %s Γ %s (ref: %s)", quantity, item, reference_id)
|
| 110 |
+
return record
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def get_stock_level(item: str) -> int | float:
|
| 114 |
+
"""
|
| 115 |
+
Calculate the current stock level for an item by summing all deltas.
|
| 116 |
+
|
| 117 |
+
Args:
|
| 118 |
+
item: Name of the inventory item (case-insensitive match).
|
| 119 |
+
|
| 120 |
+
Returns:
|
| 121 |
+
Net stock level (int or float). Returns 0 if no records found.
|
| 122 |
+
|
| 123 |
+
Example:
|
| 124 |
+
>>> get_stock_level("kurti")
|
| 125 |
+
47
|
| 126 |
+
"""
|
| 127 |
+
df = read_sheet("Inventory")
|
| 128 |
+
|
| 129 |
+
if df.empty or "item" not in df.columns:
|
| 130 |
+
return 0
|
| 131 |
+
|
| 132 |
+
item_rows = df[df["item"].str.lower() == item.lower()]
|
| 133 |
+
|
| 134 |
+
if item_rows.empty:
|
| 135 |
+
return 0
|
| 136 |
+
|
| 137 |
+
total = 0
|
| 138 |
+
for _, row in item_rows.iterrows():
|
| 139 |
+
change = row.get("change", 0) or 0
|
| 140 |
+
direction = row.get("direction", "out")
|
| 141 |
+
if direction == "in":
|
| 142 |
+
total += change
|
| 143 |
+
else:
|
| 144 |
+
total -= change
|
| 145 |
+
|
| 146 |
+
return max(total, 0) # Stock can't go below 0 in the display
|
services/invoice_service.py
CHANGED
|
@@ -1,9 +1,96 @@
|
|
| 1 |
"""
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
invoice_service.py
|
| 3 |
+
------------------
|
| 4 |
+
Invoice generation service for Notiflow.
|
| 5 |
+
|
| 6 |
+
Responsibility: generate a structured invoice object only.
|
| 7 |
+
Excel persistence is handled separately by the skill layer.
|
| 8 |
+
|
| 9 |
+
Invoice ID format: INV-YYYYMMDD-XXXX
|
| 10 |
+
- YYYYMMDD today's UTC date
|
| 11 |
+
- XXXX 4-character alphanumeric suffix (uppercase)
|
| 12 |
+
|
| 13 |
+
Public API
|
| 14 |
+
----------
|
| 15 |
+
generate_invoice(customer, item, quantity, unit_price=0.0) -> dict
|
| 16 |
"""
|
| 17 |
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import random
|
| 21 |
+
import string
|
| 22 |
+
import logging
|
| 23 |
+
from datetime import datetime, timezone
|
| 24 |
+
from typing import Optional
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _now_iso() -> str:
|
| 30 |
+
return datetime.now(timezone.utc).isoformat()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _make_invoice_id() -> str:
|
| 34 |
+
"""
|
| 35 |
+
Format: INV-YYYYMMDD-XXXX
|
| 36 |
+
XXXX = random 4-char uppercase alphanumeric suffix.
|
| 37 |
+
No file read needed β random suffix avoids collisions for demo scale.
|
| 38 |
+
"""
|
| 39 |
+
date_part = datetime.now(timezone.utc).strftime("%Y%m%d")
|
| 40 |
+
suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=4))
|
| 41 |
+
return f"INV-{date_part}-{suffix}"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def generate_invoice(
|
| 45 |
+
customer: Optional[str],
|
| 46 |
+
item: Optional[str],
|
| 47 |
+
quantity: Optional[int | float],
|
| 48 |
+
unit_price: float = 0.0,
|
| 49 |
+
order_id: Optional[str] = None,
|
| 50 |
+
) -> dict:
|
| 51 |
+
"""
|
| 52 |
+
Generate a structured invoice object.
|
| 53 |
+
|
| 54 |
+
Does NOT write to Excel β the calling skill persists the result.
|
| 55 |
+
|
| 56 |
+
Args:
|
| 57 |
+
customer: Customer name (may be None).
|
| 58 |
+
item: Item name (may be None).
|
| 59 |
+
quantity: Quantity ordered (may be None).
|
| 60 |
+
unit_price: Price per unit. Defaults to 0.0.
|
| 61 |
+
order_id: Optional linked order ID.
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
{
|
| 65 |
+
"invoice_id": "INV-20260315-AB12",
|
| 66 |
+
"timestamp": ISO-8601 str,
|
| 67 |
+
"order_id": str | None,
|
| 68 |
+
"customer": str | None,
|
| 69 |
+
"item": str | None,
|
| 70 |
+
"quantity": int | float | None,
|
| 71 |
+
"unit_price": float,
|
| 72 |
+
"total_amount": float,
|
| 73 |
+
"status": "pending"
|
| 74 |
+
}
|
| 75 |
+
"""
|
| 76 |
+
invoice_id = _make_invoice_id()
|
| 77 |
+
qty = quantity or 0
|
| 78 |
+
total_amount = round(float(qty) * unit_price, 2)
|
| 79 |
+
|
| 80 |
+
invoice = {
|
| 81 |
+
"invoice_id": invoice_id,
|
| 82 |
+
"timestamp": _now_iso(),
|
| 83 |
+
"order_id": order_id,
|
| 84 |
+
"customer": customer,
|
| 85 |
+
"item": item,
|
| 86 |
+
"quantity": quantity,
|
| 87 |
+
"unit_price": unit_price,
|
| 88 |
+
"total_amount": total_amount,
|
| 89 |
+
"status": "pending",
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
logger.info(
|
| 93 |
+
"Invoice generated: %s | customer=%s item=%s qty=%s total=%.2f",
|
| 94 |
+
invoice_id, customer, item, quantity, total_amount,
|
| 95 |
+
)
|
| 96 |
+
return invoice
|
skills/credit_skill.py
CHANGED
|
@@ -1,9 +1,69 @@
|
|
| 1 |
"""
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
credit_skill.py
|
| 3 |
+
---------------
|
| 4 |
+
Business Skill: Credit / Udhar (Stage 6 β with persistence)
|
| 5 |
+
|
| 6 |
+
Handles the "credit" intent.
|
| 7 |
+
Appends a credit entry to the Ledger sheet.
|
| 8 |
+
|
| 9 |
+
Expected input fields:
|
| 10 |
+
customer (str | None)
|
| 11 |
+
item (str | None)
|
| 12 |
+
quantity (int | None)
|
| 13 |
+
amount (int | None)
|
| 14 |
"""
|
| 15 |
|
| 16 |
+
import logging
|
| 17 |
+
from datetime import datetime, timezone
|
| 18 |
+
|
| 19 |
+
from utils.excel_writer import append_row, read_sheet
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _now_iso() -> str:
|
| 25 |
+
return datetime.now(timezone.utc).isoformat()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _generate_entry_id(prefix: str) -> str:
|
| 29 |
+
today = datetime.now(timezone.utc).strftime("%Y%m%d")
|
| 30 |
+
df = read_sheet("Ledger")
|
| 31 |
+
seq = len(df) + 1
|
| 32 |
+
return f"{prefix}-{today}-{seq:04d}"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def process_credit(data: dict) -> dict:
|
| 36 |
+
"""
|
| 37 |
+
Process a credit (udhar) event and append it to the Ledger sheet.
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
data: Extracted fields dict. Expected keys: customer, item, quantity, amount
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
{
|
| 44 |
+
"event": "credit_recorded",
|
| 45 |
+
"credit": { ledger entry }
|
| 46 |
+
}
|
| 47 |
+
"""
|
| 48 |
+
logger.info("CreditSkill processing: %s", data)
|
| 49 |
+
|
| 50 |
+
entry_id = _generate_entry_id("CRD")
|
| 51 |
+
|
| 52 |
+
credit = {
|
| 53 |
+
"entry_id": entry_id,
|
| 54 |
+
"timestamp": _now_iso(),
|
| 55 |
+
"type": "credit",
|
| 56 |
+
"customer": data.get("customer"),
|
| 57 |
+
"item": data.get("item"),
|
| 58 |
+
"quantity": data.get("quantity"),
|
| 59 |
+
"amount": data.get("amount"),
|
| 60 |
+
"payment_type": None,
|
| 61 |
+
"status": "open",
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
append_row("Ledger", credit)
|
| 65 |
+
|
| 66 |
+
return {
|
| 67 |
+
"event": "credit_recorded",
|
| 68 |
+
"credit": credit,
|
| 69 |
+
}
|
skills/order_skill.py
CHANGED
|
@@ -1,9 +1,96 @@
|
|
| 1 |
"""
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
order_skill.py
|
| 3 |
+
--------------
|
| 4 |
+
Business Skill: Order (Stage 6 + UPGRADE 1 β memory update)
|
| 5 |
+
|
| 6 |
+
On each order event this skill:
|
| 7 |
+
1. Appends an order record to the Orders sheet
|
| 8 |
+
2. Deducts stock from Inventory (delta log)
|
| 9 |
+
3. Generates an invoice object and saves it to Invoices sheet
|
| 10 |
+
4. Updates agent memory with customer + item β NEW (Upgrade 1)
|
| 11 |
+
|
| 12 |
+
Expected input fields (all may be None if not captured):
|
| 13 |
+
customer (str | None)
|
| 14 |
+
item (str | None)
|
| 15 |
+
quantity (int | None)
|
| 16 |
"""
|
| 17 |
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import logging
|
| 21 |
+
from datetime import datetime, timezone
|
| 22 |
+
|
| 23 |
+
from utils.excel_writer import append_row, read_sheet
|
| 24 |
+
from services.invoice_service import generate_invoice
|
| 25 |
+
from services.inventory_service import deduct_stock
|
| 26 |
+
from memory.agent_memory import update_memory
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _now_iso() -> str:
|
| 32 |
+
return datetime.now(timezone.utc).isoformat()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _generate_order_id() -> str:
|
| 36 |
+
today = datetime.now(timezone.utc).strftime("%Y%m%d")
|
| 37 |
+
df = read_sheet("Orders")
|
| 38 |
+
seq = len(df) + 1
|
| 39 |
+
return f"ORD-{today}-{seq:04d}"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def process_order(data: dict) -> dict:
|
| 43 |
+
"""
|
| 44 |
+
Process an order event: persist order, update inventory, generate invoice,
|
| 45 |
+
and update agent memory.
|
| 46 |
+
|
| 47 |
+
Args:
|
| 48 |
+
data: Validated extraction dict. Keys: customer, item, quantity.
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
{
|
| 52 |
+
"event": "order_received",
|
| 53 |
+
"order": { order record },
|
| 54 |
+
"invoice": { invoice record }
|
| 55 |
+
}
|
| 56 |
+
"""
|
| 57 |
+
logger.info("OrderSkill β %s", data)
|
| 58 |
+
|
| 59 |
+
customer = data.get("customer")
|
| 60 |
+
item = data.get("item")
|
| 61 |
+
quantity = data.get("quantity")
|
| 62 |
+
order_id = _generate_order_id()
|
| 63 |
+
|
| 64 |
+
# 1 ββ Persist order ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 65 |
+
order = {
|
| 66 |
+
"order_id": order_id,
|
| 67 |
+
"timestamp": _now_iso(),
|
| 68 |
+
"customer": customer,
|
| 69 |
+
"item": item,
|
| 70 |
+
"quantity": quantity,
|
| 71 |
+
"status": "pending",
|
| 72 |
+
}
|
| 73 |
+
append_row("Orders", order)
|
| 74 |
+
|
| 75 |
+
# 2 ββ Inventory deduction ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 76 |
+
if item and quantity:
|
| 77 |
+
deduct_stock(item, quantity, reference_id=order_id, note="order fulfilled")
|
| 78 |
+
|
| 79 |
+
# 3 ββ Invoice generation βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 80 |
+
invoice = generate_invoice(
|
| 81 |
+
customer = customer,
|
| 82 |
+
item = item,
|
| 83 |
+
quantity = quantity,
|
| 84 |
+
order_id = order_id,
|
| 85 |
+
unit_price = 0.0,
|
| 86 |
+
)
|
| 87 |
+
append_row("Invoices", invoice)
|
| 88 |
+
|
| 89 |
+
# 4 ββ Memory update ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 90 |
+
update_memory(customer=customer, item=item)
|
| 91 |
+
|
| 92 |
+
return {
|
| 93 |
+
"event": "order_received",
|
| 94 |
+
"order": order,
|
| 95 |
+
"invoice": invoice,
|
| 96 |
+
}
|
skills/payment_skill.py
CHANGED
|
@@ -1,9 +1,68 @@
|
|
| 1 |
"""
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
payment_skill.py
|
| 3 |
+
----------------
|
| 4 |
+
Business Skill: Payment (Stage 6 β with persistence)
|
| 5 |
+
|
| 6 |
+
Handles the "payment" intent.
|
| 7 |
+
Appends a payment entry to the Ledger sheet.
|
| 8 |
+
|
| 9 |
+
Expected input fields:
|
| 10 |
+
customer (str | None) β name of the person who sent money
|
| 11 |
+
amount (int | None) β monetary amount
|
| 12 |
+
payment_type (str | None) β "cash", "upi", "online", "cheque", or None
|
| 13 |
"""
|
| 14 |
|
| 15 |
+
import logging
|
| 16 |
+
from datetime import datetime, timezone
|
| 17 |
+
|
| 18 |
+
from utils.excel_writer import append_row, read_sheet
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _now_iso() -> str:
|
| 24 |
+
return datetime.now(timezone.utc).isoformat()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _generate_entry_id(prefix: str) -> str:
|
| 28 |
+
today = datetime.now(timezone.utc).strftime("%Y%m%d")
|
| 29 |
+
df = read_sheet("Ledger")
|
| 30 |
+
seq = len(df) + 1
|
| 31 |
+
return f"{prefix}-{today}-{seq:04d}"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def process_payment(data: dict) -> dict:
|
| 35 |
+
"""
|
| 36 |
+
Process a payment event and append it to the Ledger sheet.
|
| 37 |
+
|
| 38 |
+
Args:
|
| 39 |
+
data: Extracted fields dict. Expected keys: customer, amount, payment_type
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
{
|
| 43 |
+
"event": "payment_recorded",
|
| 44 |
+
"payment": { ledger entry }
|
| 45 |
+
}
|
| 46 |
+
"""
|
| 47 |
+
logger.info("PaymentSkill processing: %s", data)
|
| 48 |
+
|
| 49 |
+
entry_id = _generate_entry_id("PAY")
|
| 50 |
+
|
| 51 |
+
payment = {
|
| 52 |
+
"entry_id": entry_id,
|
| 53 |
+
"timestamp": _now_iso(),
|
| 54 |
+
"type": "payment",
|
| 55 |
+
"customer": data.get("customer"),
|
| 56 |
+
"item": None,
|
| 57 |
+
"quantity": None,
|
| 58 |
+
"amount": data.get("amount"),
|
| 59 |
+
"payment_type": data.get("payment_type"),
|
| 60 |
+
"status": "received",
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
append_row("Ledger", payment)
|
| 64 |
+
|
| 65 |
+
return {
|
| 66 |
+
"event": "payment_recorded",
|
| 67 |
+
"payment": payment,
|
| 68 |
+
}
|
skills/preparation_skill.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
preparation_skill.py
|
| 3 |
+
--------------------
|
| 4 |
+
Business Skill: Preparation / Inventory Pack (Stage 6 β with persistence)
|
| 5 |
+
|
| 6 |
+
Handles the "preparation" intent.
|
| 7 |
+
Appends a preparation task to the Inventory sheet as a "reserved" movement.
|
| 8 |
+
|
| 9 |
+
This records that stock is being set aside / packed, without fully
|
| 10 |
+
deducting it (deduction happens when the linked order ships).
|
| 11 |
+
If no linked order exists (standalone prep task), the record still logs
|
| 12 |
+
the intention for the shop owner's reference.
|
| 13 |
+
|
| 14 |
+
Expected input fields:
|
| 15 |
+
item (str | None) β item to prepare or pack
|
| 16 |
+
quantity (int | None) β number of units to prepare
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import logging
|
| 20 |
+
from datetime import datetime, timezone
|
| 21 |
+
|
| 22 |
+
from utils.excel_writer import append_row, read_sheet
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
# Internal helpers
|
| 29 |
+
# ---------------------------------------------------------------------------
|
| 30 |
+
|
| 31 |
+
def _now_iso() -> str:
|
| 32 |
+
return datetime.now(timezone.utc).isoformat()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _generate_prep_id() -> str:
|
| 36 |
+
"""Generate a sequential preparation ID: PREP-YYYYMMDD-XXXX."""
|
| 37 |
+
today = datetime.now(timezone.utc).strftime("%Y%m%d")
|
| 38 |
+
# Count existing preparation entries in Inventory to sequence the ID
|
| 39 |
+
df = read_sheet("Inventory")
|
| 40 |
+
prep_rows = df[df["direction"] == "reserved"] if not df.empty and "direction" in df.columns else df
|
| 41 |
+
seq = len(prep_rows) + 1
|
| 42 |
+
return f"PREP-{today}-{seq:04d}"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
# Public API
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
|
| 49 |
+
def process_preparation(data: dict) -> dict:
|
| 50 |
+
"""
|
| 51 |
+
Process a preparation / packing task and log it to the Inventory sheet.
|
| 52 |
+
|
| 53 |
+
The movement is logged with direction="reserved" so it is visible in
|
| 54 |
+
the inventory log but does not reduce the available stock count until
|
| 55 |
+
the items actually ship.
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
data: Extracted fields dict from the Extraction Agent.
|
| 59 |
+
Expected keys: item, quantity
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
{
|
| 63 |
+
"event": "preparation_queued",
|
| 64 |
+
"preparation": {
|
| 65 |
+
"prep_id": str,
|
| 66 |
+
"timestamp": ISO-8601 str,
|
| 67 |
+
"item": str | None,
|
| 68 |
+
"quantity": int | None,
|
| 69 |
+
"status": "queued"
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
Example:
|
| 74 |
+
>>> process_preparation({"item": "kurti", "quantity": 3})
|
| 75 |
+
{
|
| 76 |
+
"event": "preparation_queued",
|
| 77 |
+
"preparation": {
|
| 78 |
+
"prep_id": "PREP-20240115-0001",
|
| 79 |
+
"timestamp": "...",
|
| 80 |
+
"item": "kurti",
|
| 81 |
+
"quantity": 3,
|
| 82 |
+
"status": "queued"
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
"""
|
| 86 |
+
logger.info("PreparationSkill processing: %s", data)
|
| 87 |
+
|
| 88 |
+
item = data.get("item")
|
| 89 |
+
quantity = data.get("quantity")
|
| 90 |
+
prep_id = _generate_prep_id()
|
| 91 |
+
|
| 92 |
+
# Log to Inventory sheet as a "reserved" movement
|
| 93 |
+
if item:
|
| 94 |
+
inventory_record = {
|
| 95 |
+
"timestamp": _now_iso(),
|
| 96 |
+
"item": item,
|
| 97 |
+
"change": quantity or 0,
|
| 98 |
+
"direction": "reserved",
|
| 99 |
+
"reference_id": prep_id,
|
| 100 |
+
"note": "preparation task queued",
|
| 101 |
+
}
|
| 102 |
+
append_row("Inventory", inventory_record)
|
| 103 |
+
|
| 104 |
+
prep = {
|
| 105 |
+
"prep_id": prep_id,
|
| 106 |
+
"timestamp": _now_iso(),
|
| 107 |
+
"item": item,
|
| 108 |
+
"quantity": quantity,
|
| 109 |
+
"status": "queued",
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
logger.info("Preparation task logged: %s", prep_id)
|
| 113 |
+
|
| 114 |
+
return {
|
| 115 |
+
"event": "preparation_queued",
|
| 116 |
+
"preparation": prep,
|
| 117 |
+
}
|
skills/return_skill.py
CHANGED
|
@@ -1,9 +1,103 @@
|
|
| 1 |
"""
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
return_skill.py
|
| 3 |
+
---------------
|
| 4 |
+
Business Skill: Return / Exchange (Stage 6 β with persistence)
|
| 5 |
+
|
| 6 |
+
Handles the "return" intent.
|
| 7 |
+
Appends a return record to the Returns sheet in notiflow_data.xlsx.
|
| 8 |
+
|
| 9 |
+
Inventory is NOT updated here. Stock is only added back once the return
|
| 10 |
+
status changes to "approved" β to be handled in a future stage via a
|
| 11 |
+
status-update workflow.
|
| 12 |
+
|
| 13 |
+
Expected input fields:
|
| 14 |
+
customer (str | None) β customer making the return
|
| 15 |
+
item (str | None) β item being returned or exchanged
|
| 16 |
+
reason (str | None) β reason for return (e.g. "size issue", "damaged")
|
| 17 |
"""
|
| 18 |
|
| 19 |
+
import logging
|
| 20 |
+
from datetime import datetime, timezone
|
| 21 |
+
|
| 22 |
+
from utils.excel_writer import append_row, read_sheet
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
# Internal helpers
|
| 29 |
+
# ---------------------------------------------------------------------------
|
| 30 |
+
|
| 31 |
+
def _now_iso() -> str:
|
| 32 |
+
return datetime.now(timezone.utc).isoformat()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _generate_return_id() -> str:
|
| 36 |
+
"""Generate a sequential return ID: RET-YYYYMMDD-XXXX."""
|
| 37 |
+
today = datetime.now(timezone.utc).strftime("%Y%m%d")
|
| 38 |
+
df = read_sheet("Returns")
|
| 39 |
+
seq = len(df) + 1
|
| 40 |
+
return f"RET-{today}-{seq:04d}"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ---------------------------------------------------------------------------
|
| 44 |
+
# Public API
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
|
| 47 |
+
def process_return(data: dict) -> dict:
|
| 48 |
+
"""
|
| 49 |
+
Process a return / exchange event and persist it to the Returns sheet.
|
| 50 |
+
|
| 51 |
+
Inventory is NOT updated here β stock is only restored after approval.
|
| 52 |
+
|
| 53 |
+
Args:
|
| 54 |
+
data: Extracted fields dict from the Extraction Agent.
|
| 55 |
+
Expected keys: customer, item, reason
|
| 56 |
+
|
| 57 |
+
Returns:
|
| 58 |
+
{
|
| 59 |
+
"event": "return_requested",
|
| 60 |
+
"return": {
|
| 61 |
+
"return_id": str,
|
| 62 |
+
"timestamp": ISO-8601 str,
|
| 63 |
+
"customer": str | None,
|
| 64 |
+
"item": str | None,
|
| 65 |
+
"reason": str | None,
|
| 66 |
+
"status": "pending_review"
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
Example:
|
| 71 |
+
>>> process_return({"customer": None, "item": None, "reason": "size issue"})
|
| 72 |
+
{
|
| 73 |
+
"event": "return_requested",
|
| 74 |
+
"return": {
|
| 75 |
+
"return_id": "RET-20240115-0001",
|
| 76 |
+
"timestamp": "...",
|
| 77 |
+
"customer": None,
|
| 78 |
+
"item": None,
|
| 79 |
+
"reason": "size issue",
|
| 80 |
+
"status": "pending_review"
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
"""
|
| 84 |
+
logger.info("ReturnSkill processing: %s", data)
|
| 85 |
+
|
| 86 |
+
return_id = _generate_return_id()
|
| 87 |
+
|
| 88 |
+
return_entry = {
|
| 89 |
+
"return_id": return_id,
|
| 90 |
+
"timestamp": _now_iso(),
|
| 91 |
+
"customer": data.get("customer"),
|
| 92 |
+
"item": data.get("item"),
|
| 93 |
+
"reason": data.get("reason"),
|
| 94 |
+
"status": "pending_review",
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
append_row("Returns", return_entry)
|
| 98 |
+
logger.info("Return logged: %s", return_id)
|
| 99 |
+
|
| 100 |
+
return {
|
| 101 |
+
"event": "return_requested",
|
| 102 |
+
"return": return_entry,
|
| 103 |
+
}
|
utils/excel_writer.py
CHANGED
|
@@ -1,9 +1,164 @@
|
|
| 1 |
"""
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
utils/excel_writer.py
|
| 3 |
+
---------------------
|
| 4 |
+
Excel persistence utility for Notiflow. (FIX 4 β config-driven paths)
|
| 5 |
+
|
| 6 |
+
All file paths come from app/config.py β no hardcoded paths in this module.
|
| 7 |
+
|
| 8 |
+
Single-file, multi-sheet store: DATA_FILE (default: data/notiflow_data.xlsx)
|
| 9 |
+
|
| 10 |
+
Sheets and their column schemas:
|
| 11 |
+
Orders β order records
|
| 12 |
+
Ledger β payment and credit entries
|
| 13 |
+
Returns β return / exchange requests
|
| 14 |
+
Inventory β stock movement delta log
|
| 15 |
+
Invoices β generated invoice records
|
| 16 |
+
|
| 17 |
+
Public API
|
| 18 |
+
----------
|
| 19 |
+
append_row(sheet_name, record) β append one row, atomic save
|
| 20 |
+
append_rows(sheet_name, records) β append many rows, one save
|
| 21 |
+
read_sheet(sheet_name) -> DataFrame β read sheet into pandas DataFrame
|
| 22 |
"""
|
| 23 |
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import logging
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
|
| 29 |
+
import pandas as pd
|
| 30 |
+
from openpyxl import load_workbook, Workbook
|
| 31 |
+
|
| 32 |
+
from app.config import DATA_FILE # FIX 4: single source of truth
|
| 33 |
+
|
| 34 |
+
logger = logging.getLogger(__name__)
|
| 35 |
+
|
| 36 |
+
EXCEL_FILE = Path(DATA_FILE) # re-export for modules that imported it directly
|
| 37 |
+
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
# Canonical column schemas
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
|
| 42 |
+
SHEET_SCHEMAS: dict[str, list[str]] = {
|
| 43 |
+
"Orders": [
|
| 44 |
+
"order_id", "timestamp", "customer", "item", "quantity", "status"
|
| 45 |
+
],
|
| 46 |
+
"Ledger": [
|
| 47 |
+
"entry_id", "timestamp", "type", "customer", "item",
|
| 48 |
+
"quantity", "amount", "payment_type", "status"
|
| 49 |
+
],
|
| 50 |
+
"Returns": [
|
| 51 |
+
"return_id", "timestamp", "customer", "item", "reason", "status"
|
| 52 |
+
],
|
| 53 |
+
"Inventory": [
|
| 54 |
+
"timestamp", "item", "change", "direction", "reference_id", "note"
|
| 55 |
+
],
|
| 56 |
+
"Invoices": [
|
| 57 |
+
"invoice_id", "timestamp", "order_id", "customer",
|
| 58 |
+
"item", "quantity", "unit_price", "total_amount", "status"
|
| 59 |
+
],
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ---------------------------------------------------------------------------
|
| 64 |
+
# Internal helpers
|
| 65 |
+
# ---------------------------------------------------------------------------
|
| 66 |
+
|
| 67 |
+
def _ensure_file() -> None:
|
| 68 |
+
"""Create the Excel file with all sheets if it does not exist."""
|
| 69 |
+
if EXCEL_FILE.exists():
|
| 70 |
+
return
|
| 71 |
+
|
| 72 |
+
EXCEL_FILE.parent.mkdir(parents=True, exist_ok=True)
|
| 73 |
+
logger.info("Creating new Excel file: %s", EXCEL_FILE)
|
| 74 |
+
wb = Workbook()
|
| 75 |
+
|
| 76 |
+
if "Sheet" in wb.sheetnames:
|
| 77 |
+
del wb["Sheet"]
|
| 78 |
+
|
| 79 |
+
for sheet_name, columns in SHEET_SCHEMAS.items():
|
| 80 |
+
ws = wb.create_sheet(title=sheet_name)
|
| 81 |
+
ws.append(columns)
|
| 82 |
+
|
| 83 |
+
wb.save(EXCEL_FILE)
|
| 84 |
+
logger.info("Excel file created with sheets: %s", list(SHEET_SCHEMAS.keys()))
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _ensure_sheet(wb: Workbook, sheet_name: str) -> None:
|
| 88 |
+
if sheet_name not in wb.sheetnames:
|
| 89 |
+
ws = wb.create_sheet(title=sheet_name)
|
| 90 |
+
columns = SHEET_SCHEMAS.get(sheet_name, [])
|
| 91 |
+
if columns:
|
| 92 |
+
ws.append(columns)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# ---------------------------------------------------------------------------
|
| 96 |
+
# Public API
|
| 97 |
+
# ---------------------------------------------------------------------------
|
| 98 |
+
|
| 99 |
+
def append_row(sheet_name: str, record: dict) -> None:
|
| 100 |
+
"""
|
| 101 |
+
Append a single record to the named Excel sheet.
|
| 102 |
+
|
| 103 |
+
Missing columns default to None. Extra keys in record are ignored.
|
| 104 |
+
File and sheet are created automatically if they don't exist.
|
| 105 |
+
|
| 106 |
+
Args:
|
| 107 |
+
sheet_name: e.g. "Orders", "Ledger"
|
| 108 |
+
record: Dict of column β value pairs.
|
| 109 |
+
|
| 110 |
+
Raises:
|
| 111 |
+
ValueError: Unknown sheet_name.
|
| 112 |
+
"""
|
| 113 |
+
if sheet_name not in SHEET_SCHEMAS:
|
| 114 |
+
raise ValueError(
|
| 115 |
+
f"Unknown sheet '{sheet_name}'. Valid: {list(SHEET_SCHEMAS)}"
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
_ensure_file()
|
| 119 |
+
wb = load_workbook(EXCEL_FILE)
|
| 120 |
+
_ensure_sheet(wb, sheet_name)
|
| 121 |
+
|
| 122 |
+
ws = wb[sheet_name]
|
| 123 |
+
columns = SHEET_SCHEMAS[sheet_name]
|
| 124 |
+
ws.append([record.get(col) for col in columns])
|
| 125 |
+
|
| 126 |
+
wb.save(EXCEL_FILE)
|
| 127 |
+
logger.debug("Row appended to '%s': %s", sheet_name, record)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def append_rows(sheet_name: str, records: list[dict]) -> None:
|
| 131 |
+
"""Append multiple records in one file open/save cycle."""
|
| 132 |
+
if not records:
|
| 133 |
+
return
|
| 134 |
+
if sheet_name not in SHEET_SCHEMAS:
|
| 135 |
+
raise ValueError(f"Unknown sheet '{sheet_name}'.")
|
| 136 |
+
|
| 137 |
+
_ensure_file()
|
| 138 |
+
wb = load_workbook(EXCEL_FILE)
|
| 139 |
+
_ensure_sheet(wb, sheet_name)
|
| 140 |
+
|
| 141 |
+
ws = wb[sheet_name]
|
| 142 |
+
columns = SHEET_SCHEMAS[sheet_name]
|
| 143 |
+
for record in records:
|
| 144 |
+
ws.append([record.get(col) for col in columns])
|
| 145 |
+
|
| 146 |
+
wb.save(EXCEL_FILE)
|
| 147 |
+
logger.debug("Appended %d rows to '%s'", len(records), sheet_name)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def read_sheet(sheet_name: str) -> pd.DataFrame:
|
| 151 |
+
"""
|
| 152 |
+
Read a sheet into a DataFrame.
|
| 153 |
+
|
| 154 |
+
Returns an empty DataFrame (with correct columns) if the file or
|
| 155 |
+
sheet does not exist yet.
|
| 156 |
+
"""
|
| 157 |
+
columns = SHEET_SCHEMAS.get(sheet_name, [])
|
| 158 |
+
if not EXCEL_FILE.exists():
|
| 159 |
+
return pd.DataFrame(columns=columns)
|
| 160 |
+
try:
|
| 161 |
+
return pd.read_excel(EXCEL_FILE, sheet_name=sheet_name)
|
| 162 |
+
except Exception as exc:
|
| 163 |
+
logger.warning("Could not read sheet '%s': %s", sheet_name, exc)
|
| 164 |
+
return pd.DataFrame(columns=columns)
|
validators/__pycache__/data_validator.cpython-311.pyc
ADDED
|
Binary file (5.19 kB). View file
|
|
|
validators/data_validator.py
CHANGED
|
@@ -1,9 +1,128 @@
|
|
| 1 |
"""
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
class DataValidator:
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
def
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
data_validator.py
|
| 3 |
+
-----------------
|
| 4 |
+
Validation and normalization utilities for extracted business data.
|
| 5 |
+
|
| 6 |
+
Used in the orchestrator pipeline between extraction and skill routing:
|
| 7 |
+
|
| 8 |
+
extract_fields() β validate_data() β route_to_skill()
|
| 9 |
+
|
| 10 |
+
The validator normalises:
|
| 11 |
+
- text fields (customer, item, reason) β stripped, lowercased or title-cased
|
| 12 |
+
- payment_type aliases (gpay β upi, paytm β upi, etc.)
|
| 13 |
+
- numeric fields (amount, quantity) β int or float, never negative
|
| 14 |
"""
|
| 15 |
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import re
|
| 19 |
+
from typing import Any
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
_NUMBER_PATTERN = re.compile(r"-?\d+(?:\.\d+)?")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
class DataValidator:
|
| 26 |
+
"""Validate and normalize extraction-agent output."""
|
| 27 |
+
|
| 28 |
+
def validate(self, intent: str, data: dict[str, Any]) -> dict[str, Any]:
|
| 29 |
+
"""Return a cleaned copy of extracted data for the given intent."""
|
| 30 |
+
cleaned = dict(data or {})
|
| 31 |
+
|
| 32 |
+
if "customer" in cleaned:
|
| 33 |
+
cleaned["customer"] = self._clean_text(cleaned.get("customer"), title=True)
|
| 34 |
+
if "item" in cleaned:
|
| 35 |
+
cleaned["item"] = self._clean_text(cleaned.get("item"))
|
| 36 |
+
if "reason" in cleaned:
|
| 37 |
+
cleaned["reason"] = self._clean_text(cleaned.get("reason"))
|
| 38 |
+
if "payment_type" in cleaned:
|
| 39 |
+
cleaned["payment_type"] = self._normalize_payment_type(cleaned.get("payment_type"))
|
| 40 |
+
if "amount" in cleaned:
|
| 41 |
+
cleaned["amount"] = self._to_number(cleaned.get("amount"), as_int_if_possible=True)
|
| 42 |
+
if "quantity" in cleaned:
|
| 43 |
+
cleaned["quantity"] = self._to_number(cleaned.get("quantity"), as_int_if_possible=True)
|
| 44 |
+
|
| 45 |
+
# Business rules: amounts and quantities must be positive
|
| 46 |
+
if intent == "payment" and cleaned.get("amount") is not None and cleaned["amount"] < 0:
|
| 47 |
+
cleaned["amount"] = abs(cleaned["amount"])
|
| 48 |
+
if (
|
| 49 |
+
intent in {"order", "credit", "preparation"}
|
| 50 |
+
and cleaned.get("quantity") is not None
|
| 51 |
+
and cleaned["quantity"] < 0
|
| 52 |
+
):
|
| 53 |
+
cleaned["quantity"] = abs(cleaned["quantity"])
|
| 54 |
+
|
| 55 |
+
return cleaned
|
| 56 |
+
|
| 57 |
+
@staticmethod
|
| 58 |
+
def _clean_text(value: Any, *, title: bool = False) -> str | None:
|
| 59 |
+
if value is None:
|
| 60 |
+
return None
|
| 61 |
+
text = str(value).strip()
|
| 62 |
+
if not text:
|
| 63 |
+
return None
|
| 64 |
+
text = re.sub(r"\s+", " ", text)
|
| 65 |
+
return text.title() if title else text.lower()
|
| 66 |
+
|
| 67 |
+
@staticmethod
|
| 68 |
+
def _normalize_payment_type(value: Any) -> str | None:
|
| 69 |
+
text = DataValidator._clean_text(value)
|
| 70 |
+
if text is None:
|
| 71 |
+
return None
|
| 72 |
+
|
| 73 |
+
aliases = {
|
| 74 |
+
"gpay": "upi",
|
| 75 |
+
"google pay": "upi",
|
| 76 |
+
"phonepe": "upi",
|
| 77 |
+
"phone pe": "upi",
|
| 78 |
+
"paytm": "upi",
|
| 79 |
+
"upi": "upi",
|
| 80 |
+
"cash": "cash",
|
| 81 |
+
"online": "online",
|
| 82 |
+
"bank transfer": "online",
|
| 83 |
+
"neft": "online",
|
| 84 |
+
"imps": "online",
|
| 85 |
+
"rtgs": "online",
|
| 86 |
+
"cheque": "cheque",
|
| 87 |
+
"check": "cheque",
|
| 88 |
+
}
|
| 89 |
+
return aliases.get(text, text)
|
| 90 |
+
|
| 91 |
+
@staticmethod
|
| 92 |
+
def _to_number(value: Any, *, as_int_if_possible: bool = False) -> int | float | None:
|
| 93 |
+
if value is None or value == "":
|
| 94 |
+
return None
|
| 95 |
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
| 96 |
+
number = float(value)
|
| 97 |
+
else:
|
| 98 |
+
text = str(value).replace(",", "").lower()
|
| 99 |
+
match = _NUMBER_PATTERN.search(text)
|
| 100 |
+
if not match:
|
| 101 |
+
return None
|
| 102 |
+
number = float(match.group(0))
|
| 103 |
+
if as_int_if_possible and number.is_integer():
|
| 104 |
+
return int(number)
|
| 105 |
+
return number
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# ---------------------------------------------------------------------------
|
| 109 |
+
# Convenience wrapper β used by the orchestrator
|
| 110 |
+
# ---------------------------------------------------------------------------
|
| 111 |
+
|
| 112 |
+
def validate_data(intent: str, data: dict[str, Any]) -> dict[str, Any]:
|
| 113 |
+
"""
|
| 114 |
+
Normalise and validate extracted data for the given intent.
|
| 115 |
+
|
| 116 |
+
This is the function the orchestrator imports:
|
| 117 |
+
|
| 118 |
+
from validators.data_validator import validate_data
|
| 119 |
+
cleaned = validate_data(intent, raw_data)
|
| 120 |
+
|
| 121 |
+
Args:
|
| 122 |
+
intent: Detected intent string (e.g. "payment", "order").
|
| 123 |
+
data: Raw extraction dict from the Extraction Agent.
|
| 124 |
+
|
| 125 |
+
Returns:
|
| 126 |
+
Cleaned, normalised copy of the data dict.
|
| 127 |
+
"""
|
| 128 |
+
return DataValidator().validate(intent, data)
|