diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa76d8d..ced36f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,12 +76,28 @@ jobs: sudo apt update sudo apt-get install -y mariadb-client + # - name: Setup Bench & DB + # run: | + # pip install frappe-bench<=15 + # bench init --skip-redis-config-generation --skip-assets --python "$(which python)" ~/frappe-bench + # mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'" + # mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" + - name: Setup Bench & DB run: | - pip install frappe-bench - bench init --skip-redis-config-generation --skip-assets --python "$(which python)" ~/frappe-bench - mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'" - mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" + pip install "frappe-bench<6" + bench init ~/frappe-bench \ + --frappe-branch version-15 \ + --skip-redis-config-generation \ + --skip-assets \ + --python "$(which python)" + + mariadb --host 127.0.0.1 --port 3306 -u root -proot \ + -e "SET GLOBAL character_set_server = 'utf8mb4'" + + mariadb --host 127.0.0.1 --port 3306 -u root -proot \ + -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" + - name: Install App working-directory: /home/runner/frappe-bench diff --git a/next_ai/ai/parsing.py b/next_ai/ai/parsing.py new file mode 100644 index 0000000..b0608eb --- /dev/null +++ b/next_ai/ai/parsing.py @@ -0,0 +1,158 @@ +import frappe +import os +from langchain_google_genai import ChatGoogleGenerativeAI +from frappe import _ +from pydantic import BaseModel, Field, create_model +from next_ai.ai.utils import get_parser_type_details +from next_ai.ai.prompt import PARSING_PROMPT + + +@frappe.whitelist(methods=["POST"]) +def get_ai_parser_response(**kwargs): + """ + doctype: str - The name of the doctype to parse data for. + name: str - The name of the document instance. + message: str - The user input message to be parsed. + """ + data = frappe._dict(kwargs) + + # Mandatory fields + required_fields = ["doctype", "name", "message"] + + missing = [f for f in required_fields if not data.get(f)] + if missing: + frappe.throw(f"Missing mandatory fields: {', '.join(missing)}") + + message = NextAIParsing(api_data=data).get_response() + return {"status_code":200, "status": "sucess", "message": message} + + +@frappe.whitelist(methods=["GET"]) +def get_parser_fieldtype_details(): + parser_type_details = get_parser_type_details() + return {"status_code":200, "status": "sucess", "message": parser_type_details} + + +class NextAIParsing: + """ + This class is mainly used to get the user input and parse the data inside the doctype fields. + """ + def __init__(self, api_data: dict): + self.api_data = api_data + + self.message = self.api_data.get("message") + + self.parsing_fields = self.get_parsing_field_details() + self.field_meta = get_parser_type_details() + + self.nextai_settings = self.get_nextai_settings() + self.validate_settings() + + self.current_model = self.nextai_settings.model_name + + def validate_token(self): + if len(self.api_data['message']) > 8000: + frappe.throw(_("Prompt length exceeds the maximum limit of 8000 characters. Please shorten your prompt.")) + + def validate_settings(self): + if not self.nextai_settings.model_name: + frappe.throw(_("Model name is not set in NextAI Settings. Please configure the model name.")) + if not self.nextai_settings.platform: + frappe.throw(_("Platform is not set in NextAI Settings. Please configure the platform.")) + if not self.nextai_settings.get_password("api_key"): + frappe.throw(_("API Key is not set in NextAI Settings. Please configure the API Key.")) + + def get_nextai_settings(self): + nextai_settings = frappe.get_doc('NextAI Settings') + return nextai_settings + + def get_llm(self, model_name: str = None): + model_name = model_name or self.nextai_settings.model_name + try: + if self.nextai_settings.platform == 'Gemini': + os.environ['GOOGLE_API_KEY'] = self.nextai_settings.get_password("api_key") + llm = ChatGoogleGenerativeAI(model=model_name) + return llm + except Exception as e: + frappe.log_error(frappe.get_traceback(), "Error in NextAIParsing.get_llm") + frappe.throw(_("Error in getting LLM: {0}").format(str(e))) + + def get_structured_output_llm(self, model_name: str = None, base_model: BaseModel = None): + llm = self.get_llm(model_name=model_name) + so_llm = llm.with_structured_output(base_model) + return so_llm + + def get_prompt_message(self) -> str: + prompt = PARSING_PROMPT.format(input=self.message) + return prompt + + def get_response(self) -> dict: + DynamicBaseParser = self.build_dynamic_parser() + so_llm = self.get_structured_output_llm(model_name=self.current_model, base_model=DynamicBaseParser) + message = self.get_prompt_message() + response = so_llm.invoke(message) + response = response.__dict__ + return response + + def build_dynamic_parser(self): + model_fields = {} + + for field_details in self.parsing_fields: + fields_type_detail = self.field_meta.get(field_details['field_type']) + if not fields_type_detail['is_active']: + continue + + model_fields[field_details['field_name']] = {} + _type, default_description = fields_type_detail['type'], fields_type_detail['default_description'] + + model_fields[field_details['field_name']]['type'] = _type + model_fields[field_details['field_name']]['description'] = field_details.get('description') or default_description + + default_value = None + + model_fields[field_details['field_name']] = ( + _type, + Field(default_value, description=model_fields[field_details['field_name']]['description']) + ) + # dynamically create a pydantic model + DynamicBaseParser = create_model("DynamicBaseParser", **model_fields) + return DynamicBaseParser + + def test_dynamic_parser(self): + fields = { + "status": {"type": str, "description": "Status of the task"}, + "description": {"type": str, "description": "Description of the task"}, + "priority": {"type": str, "description": "Priority level"}, + "is_completed": {"type": bool, "description": "Completion status"} + } + + DynamicParser = self.build_dynamic_parser(fields) + + # Example usage + example_data = { + "status": "Open", + "description": "This is a test task.", + "priority": "High", + "is_completed": False + } + + parsed_instance = DynamicParser(**example_data) + frappe.logger().info(f"Parsed Instance: {parsed_instance}") + return parsed_instance + + + def get_parsing_field_details(self) -> dict: + """ + Get the field details for a given parser field name. + """ + data = frappe.db.get_all( + "NextAI Parsing Details", + filters={ + "parenttype": "NextAI Parsing", + "parentfield": "parsing_details", + "parent": self.api_data.get("name"), + }, + fields=['*'], + order_by='idx' + ) + return data \ No newline at end of file diff --git a/next_ai/ai/prompt.py b/next_ai/ai/prompt.py index abbb365..3c7eeca 100644 --- a/next_ai/ai/prompt.py +++ b/next_ai/ai/prompt.py @@ -93,6 +93,19 @@ {input} """ +JSON = """ +You are a software enginer. You generate data in the JSON Format Only. Follow the instructions below: + +# Instructions +1. Read the User Input carefully. +2. Generate a response that is relevant to the User Input. +3. Ensure that the response is formatted in the JSON Only Strictly. +4. Do not include any additional text or explanations outside of the code block. +5. Follow the best Indentation and coding practices. + +# User Input +{input} +""" PROMPTS = { "Markdown Editor": MARKDOWN, @@ -102,4 +115,20 @@ "HTML Editor": HTML_EDITOR, "Text Editor": TEXT_EDITOR, "Code": CODE, -} \ No newline at end of file + "JSON": JSON +} + + +PARSING_PROMPT = """ +You are an expert data parser. Your task is to extract and structure information based on the provided field definitions. Follow the instructions below: + +# Instructions +1. Read the Field Definitions carefully. +2. Read the User Input carefully. +3. Extract relevant information from the User Input based on the Field Definitions. +4. Ensure the extracted information more grammatically correct and contextually relevant. +5. If any date related incomplete information contains, use current time infomration to fill the missing details. + +# User Input +{input} +""" \ No newline at end of file diff --git a/next_ai/ai/utils.py b/next_ai/ai/utils.py index 42d5a8d..a06db14 100644 --- a/next_ai/ai/utils.py +++ b/next_ai/ai/utils.py @@ -1,5 +1,7 @@ import frappe from frappe.utils import now_datetime +from datetime import date, datetime + def nextai_usage_log_create(**kwargs): @@ -14,3 +16,226 @@ def nextai_usage_log_create(**kwargs): doc.insert(ignore_permissions=True) except Exception as e: frappe.log_error(frappe.get_traceback(), "Nextai Usage Log Create Failed") + + +@frappe.whitelist() +def get_parser_type_details() -> dict: + parser_type_details = { + "Autocomplete": { + "type": str, + "default_description": "Get appropriate data for the field", + "is_active": False + }, + "Attach": { + "type": str, + "default_description": "Get appropriate data for the field", + "is_active": False + }, + "Attach Image": { + "type": str, + "default_description": "Get appropriate data for the field", + "is_active": False + }, + "Barcode": { + "type": str, + "default_description": "Get appropriate data for the field", + "is_active": False + }, + "Button": { + "type": str, + "default_description": "Get appropriate data for the field", + "is_active": False + }, + "Check": { + "type": str, + "default_description": "Get appropriate data for the field", + "is_active": False + }, + "Code": { + "type": str, + "default_description": "Get appropriate data for the field", + "is_active": False + }, + "Color": { + "type": str, + "default_description": "Apply color codes dynamically so that higher priorities shift toward red/orange, mid priorities toward yellow, and lower priorities toward green (#29CD42).", + "is_active": True + }, + "Column Break": { + "type": str, + "default_description": "Get appropriate data for the field", + "is_active": False + }, + "Currency": { + "type": str, + "default_description": "Get appropriate data for the field", + "is_active": False + }, + "Data": { + "type": str, + "default_description": "Get appropriate data for the field", + "is_active": True + }, + "Date": { + "type": date, + "default_description": "A calendar date in YYYY-MM-DD format.", + "is_active": True + }, + "Datetime": { + "type": datetime, + "default_description": "A full timestamp in YYYY-MM-DD HH:MM:SS format.", + "is_active": True + }, + "Duration": { + "type": int, + "default_description": "Duration value represented in total seconds (e.g., 1 hour = 3600 seconds).", + "is_active": True + }, + "Dynamic Link": { + "type": str, + "default_description": "Get appropriate data for the field", + "is_active": False + }, + "Float": { + "type": float, + "default_description": "A floating-point number with decimal precision (e.g., 3.142).", + "is_active": True + }, + "Fold": { + "type": str, + "default_description": "Get appropriate data for the field", + "is_active": False + }, + "Geolocation": { + "type": str, + "default_description": "Get appropriate data for the field", + "is_active": False + }, + "Heading": { + "type": str, + "default_description": "Get appropriate data for the field", + "is_active": False + }, + "HTML": { + "type": str, + "default_description": "HTML content supported with Bootstrap styling and components.", + "is_active": True + }, + "HTML Editor": { + "type": str, + "default_description": "HTML content supported with Bootstrap styling and components.", + "is_active": True + }, + "Icon": { + "type": str, + "default_description": "Get appropriate data for the field", + "is_active": False + }, + "Image": { + "type": str, + "default_description": "Get appropriate data for the field", + "is_active": False + }, + "Int": { + "type": int, + "default_description": "An integer value without decimals.", + "is_active": True + }, + "JSON": { + "type": str, + "default_description": "A valid JSON string.", + "is_active": False + }, + "Link": { + "type": str, + "default_description": "Get appropriate data for the field", + "is_active": False + }, + "Long Text": { + "type": str, + "default_description": "A long text value.", + "is_active": True + }, + "Markdown Editor": { + "type": str, + "default_description": "A markdown formatted text value.", + "is_active": True + }, + "Password": { + "type": str, + "default_description": "A secure password string.", + "is_active": True + }, + "Percent": { + "type": float, + "default_description": "A percentage value represented as a number.", + "is_active": False + }, + "Phone": { + "type": str, + "default_description": "A phone number including the country code. Default country code is +91. example format +91-18001234567", + "is_active": True + }, + "Read Only": { + "type": str, + "default_description": "Get appropriate data for the field", + "is_active": False + }, + "Rating": { + "type": str, + "default_description": "A rating value between 0.0 and 1.0 in increments of 0.1 (allowed: 0.0, 0.1, 0.2 ... 0.9, 1.0).", + "is_active": True + }, + "Section Break": { + "type": str, + "default_description": "Get appropriate data for the field", + "is_active": False + }, + "Select": { + "type": str, + "default_description": "Select one value from the predefined options (case-sensitive; must match exactly). The options are: Option 1, Option 2, Option 3.", + "is_active": True + }, + "Signature": { + "type": str, + "default_description": "Get appropriate data for the field", + "is_active": False + }, + "Small Text": { + "type": str, + "default_description": "Provide a short single-line text value.", + "is_active": True + }, + "Tab Break": { + "type": str, + "default_description": "Get appropriate data for the field", + "is_active": False + }, + "Table": { + "type": str, + "default_description": "Get appropriate data for the field", + "is_active": False + }, + "Table MultiSelect": { + "type": str, + "default_description": "Get appropriate data for the field", + "is_active": False + }, + "Text": { + "type": str, + "default_description": "Provide multi-line plain text content.", + "is_active": True + }, + "Text Editor": { + "type": str, + "default_description": "Provide rich-text HTML content produced by a Quill editor. Example HTML

Hi I'm an example content, This is BOLD

", + "is_active": True + }, + "Time": { + "type": str, + "default_description": "Provide a valid time value in HH:MM:SS (24-hour) format. Example: 23:59:59", + "is_active": True + } + } + return parser_type_details + diff --git a/next_ai/next_ai/doctype/nextai_parsing/__init__.py b/next_ai/next_ai/doctype/nextai_parsing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/next_ai/next_ai/doctype/nextai_parsing/nextai_parsing.js b/next_ai/next_ai/doctype/nextai_parsing/nextai_parsing.js new file mode 100644 index 0000000..6dacfc9 --- /dev/null +++ b/next_ai/next_ai/doctype/nextai_parsing/nextai_parsing.js @@ -0,0 +1,42 @@ +frappe.ui.form.on('NextAI Parsing', { + doc: function (frm) { + if (!frm.doc.doc) return; + + frm.clear_table('parsing_details'); + + // Fetch parser-type details first + frappe.call({ + method: "next_ai.ai.utils.get_parser_type_details", + callback: function(r) { + const parser_map = r.message || {}; + + // Fetch metadata of selected Doctype + frappe.model.with_doctype(frm.doc.doc, function () { + const meta = frappe.get_meta(frm.doc.doc); + + meta.fields.forEach(field => { + // skip breaks + if (['Section Break', 'Column Break', 'Tab Break'].includes(field.fieldtype)) + return; + + // get parser rules for this field.type + const rule = parser_map[field.fieldtype]; + + // skip if rule not found OR inactive + if (!rule || !rule.is_active) return; + + // Add child row + let child = frm.add_child('parsing_details'); + child.field_name = field.fieldname; + child.field_label = field.label; + child.field_type = field.fieldtype; + child.description = rule.default_description; + }); + + frm.refresh_field('parsing_details'); + frappe.msgprint(__('Active fields added from Doctype: {0}', [frm.doc.doc])); + }); + } + }); + } +}); diff --git a/next_ai/next_ai/doctype/nextai_parsing/nextai_parsing.json b/next_ai/next_ai/doctype/nextai_parsing/nextai_parsing.json new file mode 100644 index 0000000..5ee9670 --- /dev/null +++ b/next_ai/next_ai/doctype/nextai_parsing/nextai_parsing.json @@ -0,0 +1,66 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "format:{doc}-{YYYY}-{MM}-{DD}-{######}", + "creation": "2025-11-02 19:15:51.309467", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "doc", + "enable", + "section_break_xxgza", + "parsing_details" + ], + "fields": [ + { + "fieldname": "doc", + "fieldtype": "Link", + "label": "Doctype", + "options": "DocType" + }, + { + "fieldname": "parsing_details", + "fieldtype": "Table", + "label": "Parsing Details", + "options": "NextAI Parsing Details" + }, + { + "fieldname": "section_break_xxgza", + "fieldtype": "Section Break" + }, + { + "default": "1", + "fieldname": "enable", + "fieldtype": "Check", + "label": "Enable" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-12-03 09:25:22.446762", + "modified_by": "Administrator", + "module": "Next AI", + "name": "NextAI Parsing", + "naming_rule": "Expression", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/next_ai/next_ai/doctype/nextai_parsing/nextai_parsing.py b/next_ai/next_ai/doctype/nextai_parsing/nextai_parsing.py new file mode 100644 index 0000000..7b6e246 --- /dev/null +++ b/next_ai/next_ai/doctype/nextai_parsing/nextai_parsing.py @@ -0,0 +1,8 @@ +# Copyright (c) 2025, NextAI Team and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + +class NextAIParsing(Document): + pass diff --git a/next_ai/next_ai/doctype/nextai_parsing/test_nextai_parsing.py b/next_ai/next_ai/doctype/nextai_parsing/test_nextai_parsing.py new file mode 100644 index 0000000..eef7483 --- /dev/null +++ b/next_ai/next_ai/doctype/nextai_parsing/test_nextai_parsing.py @@ -0,0 +1,9 @@ +# Copyright (c) 2025, NextAI Team and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestNextAIParsing(FrappeTestCase): + pass diff --git a/next_ai/next_ai/doctype/nextai_parsing_details/__init__.py b/next_ai/next_ai/doctype/nextai_parsing_details/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/next_ai/next_ai/doctype/nextai_parsing_details/nextai_parsing_details.js b/next_ai/next_ai/doctype/nextai_parsing_details/nextai_parsing_details.js new file mode 100644 index 0000000..afd3f29 --- /dev/null +++ b/next_ai/next_ai/doctype/nextai_parsing_details/nextai_parsing_details.js @@ -0,0 +1,8 @@ +// Copyright (c) 2025, NextAI Team and contributors +// For license information, please see license.txt + +frappe.ui.form.on('NextAI Parsing Details', { + // refresh: function(frm) { + + // } +}); diff --git a/next_ai/next_ai/doctype/nextai_parsing_details/nextai_parsing_details.json b/next_ai/next_ai/doctype/nextai_parsing_details/nextai_parsing_details.json new file mode 100644 index 0000000..1f9636e --- /dev/null +++ b/next_ai/next_ai/doctype/nextai_parsing_details/nextai_parsing_details.json @@ -0,0 +1,55 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-11-02 19:18:22.047341", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "field_name", + "field_label", + "field_type", + "description" + ], + "fields": [ + { + "fieldname": "field_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Field Name" + }, + { + "fieldname": "field_label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Field Label" + }, + { + "fieldname": "field_type", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Field Type" + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Description" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-11-10 21:47:56.285297", + "modified_by": "Administrator", + "module": "Next AI", + "name": "NextAI Parsing Details", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/next_ai/next_ai/doctype/nextai_parsing_details/nextai_parsing_details.py b/next_ai/next_ai/doctype/nextai_parsing_details/nextai_parsing_details.py new file mode 100644 index 0000000..09e2e6c --- /dev/null +++ b/next_ai/next_ai/doctype/nextai_parsing_details/nextai_parsing_details.py @@ -0,0 +1,8 @@ +# Copyright (c) 2025, NextAI Team and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + +class NextAIParsingDetails(Document): + pass diff --git a/next_ai/next_ai/doctype/nextai_parsing_details/test_nextai_parsing_details.py b/next_ai/next_ai/doctype/nextai_parsing_details/test_nextai_parsing_details.py new file mode 100644 index 0000000..5cd29b5 --- /dev/null +++ b/next_ai/next_ai/doctype/nextai_parsing_details/test_nextai_parsing_details.py @@ -0,0 +1,9 @@ +# Copyright (c) 2025, NextAI Team and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestNextAIParsingDetails(FrappeTestCase): + pass diff --git a/next_ai/public/js/next_ai.js b/next_ai/public/js/next_ai.js index 18506dd..c3e7057 100644 --- a/next_ai/public/js/next_ai.js +++ b/next_ai/public/js/next_ai.js @@ -245,3 +245,169 @@ function typeText($target, text, type, callback) { requestAnimationFrame(step); } + +// ------------------------------------------------------------- + +let nextai_allowed_doctypes = null; + +/* ------------------------------------ + * Load Allowed Doctypes (once, cached) + * ------------------------------------ */ +function loadNextAIAllowedDoctypes() { + return new Promise((resolve) => { + if (nextai_allowed_doctypes) { + resolve(nextai_allowed_doctypes); + return; + } + + frappe.call({ + method: "frappe.client.get_list", + args: { + doctype: "NextAI Parsing", + fields: ["doc"], + filters: { enable: 1 } + }, + callback(r) { + nextai_allowed_doctypes = r.message?.map(d => d.doc) || []; + console.log("NextAI Allowed Doctypes:", nextai_allowed_doctypes); + resolve(nextai_allowed_doctypes); + } + }); + }); +} + +/* ------------------------------------ + * Inject Global NextAI Button + * ------------------------------------ */ +async function injectGlobalNextAIButton() { + const route = frappe.get_route(); + if (!route || route[0] !== "Form") return; + + const frm = window.cur_frm; + if (!frm) return; + + // Restricted system doctypes + if (["DocType", "Customize Form"].includes(frm.doctype)) return; + + const allowed_doctypes = await loadNextAIAllowedDoctypes(); + if (!allowed_doctypes.includes(frm.doctype)) return; + + // Avoid duplicate button + if (document.querySelector('#nextai-global-btn')) return; + + const header = document.querySelector('.page-head .page-title'); + if (!header) return; + + console.log("Injecting Fancy NextAI Button…"); + + const $icon = $('