Dipan04 commited on
Commit
0bb062b
Β·
1 Parent(s): 037ee88

bina soke kam kia

Browse files
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
- Extraction agent for extracting information from messages.
3
  """
4
 
5
- class ExtractionAgent:
6
- """Agent for information extraction."""
7
-
8
- def __init__(self):
9
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 processing user intents.
3
  """
4
 
5
- class IntentAgent:
6
- """Agent for intent detection and processing."""
7
-
8
- def __init__(self):
9
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Orchestrator for managing agent workflows.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  """
4
 
5
- class Orchestrator:
6
- """Orchestrates agent operations."""
7
-
8
- def __init__(self):
9
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Router for directing requests to appropriate handlers.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  """
4
 
5
- class Router:
6
- """Routes requests to appropriate skills or services."""
7
-
8
- def __init__(self):
9
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Configuration settings for Notiflow application.
 
 
 
 
 
3
  """
4
 
5
- class Config:
6
- """Base configuration class."""
7
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Main entry point for the Notiflow application.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  """
4
 
5
- def main():
6
- """Run the Notiflow application."""
7
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  if __name__ == "__main__":
11
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Streamlit dashboard application.
 
 
 
 
 
 
 
 
 
 
3
  """
 
 
 
 
4
 
 
 
 
 
 
 
 
5
  import streamlit as st
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
  def main():
9
- """Run the Streamlit dashboard."""
10
- st.title("Notiflow Dashboard")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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']}&nbsp;&nbsp;{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&nbsp;2&nbsp;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 parser for processing incoming messages.
3
  """
4
 
 
 
 
 
 
5
  class MessageParser:
6
- """Parses messages into structured formats."""
7
-
8
- def __init__(self):
9
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Information Extraction Prompt
2
 
3
- Extract relevant entities and information from user messages.
4
 
5
- Instructions:
6
- - Identify key entities (names, dates, amounts, order numbers, etc.)
7
- - Extract relevant attributes for the detected intent
8
- - Structure the extracted data in JSON format
9
- - Mark confidence levels for extracted information
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Intent Detection Prompt
2
 
3
- Analyze the user message and determine the primary intent.
4
 
5
- Instructions:
6
- - Identify the main action or request
7
- - Classify into categories: order, payment, credit, return, or other
8
- - Return the intent and confidence level
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Inventory service for managing inventory.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  """
4
 
5
- class InventoryService:
6
- """Handles inventory-related operations."""
7
-
8
- def __init__(self):
9
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Invoice service for managing invoices.
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  """
4
 
5
- class InvoiceService:
6
- """Handles invoice-related operations."""
7
-
8
- def __init__(self):
9
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Credit management skill.
 
 
 
 
 
 
 
 
 
 
 
3
  """
4
 
5
- class CreditSkill:
6
- """Handles credit-related operations."""
7
-
8
- def __init__(self):
9
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Order processing skill.
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  """
4
 
5
- class OrderSkill:
6
- """Handles order-related operations."""
7
-
8
- def __init__(self):
9
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Payment processing skill.
 
 
 
 
 
 
 
 
 
 
3
  """
4
 
5
- class PaymentSkill:
6
- """Handles payment-related operations."""
7
-
8
- def __init__(self):
9
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Return processing skill.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  """
4
 
5
- class ReturnSkill:
6
- """Handles return-related operations."""
7
-
8
- def __init__(self):
9
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Excel file writing utilities.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  """
4
 
5
- class ExcelWriter:
6
- """Writes data to Excel files."""
7
-
8
- def __init__(self):
9
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Data validation utilities.
 
 
 
 
 
 
 
 
 
 
 
3
  """
4
 
 
 
 
 
 
 
 
 
 
5
  class DataValidator:
6
- """Validates data against specified schemas."""
7
-
8
- def __init__(self):
9
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)