--- name: module-development-spec version: 1.0.0 description: Standardized development workflow for creating modular components following the specified directory structure, frontend/backend conventions, and database integration patterns. trigger_conditions: - User requests to create a new module named "mymodule" or similar - Task involves implementing a component with bricks-framework frontend and sqlor database backend - Development follows the documented directory structure with wwwroot, models, json, and init directories - Task involves creating read-only/dashboard modules that display data without CRUD (no models/json needed) --- ## Work logs / delivery archive For production module/application development, update a dated work log before finishing the session. Use the actual current date from the system (do not assume the date from earlier messages) and keep late-night follow-up fixes under their real date or explicitly label them as next-day follow-ups. If no log file exists, create one under an appropriate docs/archive path such as `docs/work-log-YYYY-MM-DD.md`. A useful log entry should include: - Scope/background and repository/module name. - Timeline or commit list for the date. - Key technical decisions and pitfalls discovered. - Verification performed and any environment-limited verification that could not run. - Current branch/commit state. Do not leave a task as "done" without this archive when the user expects deliverables to be retained for later inspection. This skill defines the complete workflow for developing standardized modules that integrate with the ahserver ecosystem using bricks-framework for frontend and sqlor for database operations. ## Module Philosophy — Host-Agnostic **Modules are NOT tied to any specific application.** A module is a self-contained unit that any host can load: - pipeline-app loads it → it's pipeline-app's module - sage loads it → it's sage's module - Any future application loads it → it's that application's module **Core principle**: A module only depends on: 1. Foundation packages (sqlor, ahserver for ServerEnv, appPublic for utilities) 2. Its own data tables (or shared tables like sage's appcodes) 3. Other modules it explicitly imports A module does NOT depend on: - Any specific host application's entry point - Host-specific configuration files - Host-specific wwwroot paths The `load_{module}()` function is the ONLY integration point. It registers functions to ServerEnv. The host decides how to wire it in. **Interaction-layer modules** (like pipeline-task) have NO data tables — they are pure thin wrappers that call other modules' functions via ServerEnv. They provide .dspy + .ui files for user interaction only. ## Directory Structure ``` mymodule/ # Main module directory (replace mymodule with actual name) ├── mymodule/ # Python package directory │ ├── __init__.py # Required Python package file │ ├── init.py # Module initialization script │ └── *.py # Additional source files ├── wwwroot/ # Frontend scripts and resources (.ui, .dspy files) ├── models/ # Database table definitions as JSON files ├── json/ # CRUD operation definitions as JSON files ├── init/ # Module initialization data │ └── data.json # Initial data in specified format ├── scripts/ # Supporting scripts │ └── load_path.py # RBAC permission registration script (see RBAC section below) ├── skill/ # MANDATORY — AI agent reference documentation │ └── SKILL.md # Agent-facing module spec: data model, endpoints, pitfalls ├── pyproject.toml # Python packaging configuration └── README.md # Module documentation ``` ## Core Implementation Requirements ### 1. Module Initialization (init.py) - **Primary purpose**: Register all module functions with ServerEnv instance so they can be called directly from .ui and .dspy files - **NOT for route registration**: wwwroot files are automatically routed via `/{module_name}/filename.ext` - **ServerEnv configuration is REQUIRED for all modules**: Every module must register its functions with ServerEnv in the `load_{modulename}()` function - **CRITICAL: Export functions in __init__.py**: All async functions defined in `init.py` must be explicitly imported in the package's `__init__.py` file, otherwise Sage framework cannot find them and dspy calls will fail with `NameError: name 'xxx' is not defined` **Pitfall: Triple-place function registration — adding or removing functions requires updating THREE files** When adding or removing a module function, THREE files must be updated in sync: 1. **`mymodule/mymodule.py`** (or wherever the function is defined) — the function implementation 2. **`mymodule/__init__.py`** — the import/export line 3. **`mymodule/init.py`** — the `env.xxx = xxx` registration in `load_{module}()` Forgetting any one causes runtime errors: - Missing #2 → `ImportError` or `AttributeError` when init.py tries to import - Missing #3 → `NameError: name 'xxx' is not defined` in .dspy/.ui templates - Removing from #1 but not #2/#3 → `ImportError` at module load time **Cleanup checklist when removing a function:** ``` grep -rn 'function_name' mymodule/ --include='*.py' # Must appear in exactly: definition, __init__.py import, init.py env registration # Remove from ALL three, then commit. ``` Example `__init__.py` (required): ```python # mymodule/__init__.py from .init import ( create_user, get_user_list, update_user_profile, # ... export all public API functions ) ``` - Keep init.py focused on ServerEnv registration and minimal initialization - Include a `load_{modulename}()` function that registers all public functions with ServerEnv - Functions registered with ServerEnv become directly callable from .ui templates and .dspy scripts Example structure: ```python from ahserver.serverenv import ServerEnv # Module metadata and constants MODULE_NAME = "mymodule" MODULE_VERSION = "1.0.0" # Helper functions for web scripts def get_data(): """Get data - will be registered with ServerEnv""" pass def save_data(data): """Save data - will be registered with ServerEnv""" pass def load_mymodule(): """Register all functions with ServerEnv so they can be called from .ui/.dspy files""" env = ServerEnv() env.get_data = get_data env.save_data = save_data return True ``` **Pitfall: CRUD dspy wrappers use plural table names — register both singular and plural** The `xls2ui` tool generates CRUD wrapper `.dspy` files (e.g., `add_suppliers.dspy`) that reference functions with **plural** names matching the table name: `create_suppliers`, `update_suppliers`, `delete_suppliers`. But `init.py` typically defines functions with **singular** names (e.g., `create_supplier`). If only the singular name is registered, the CRUD UI will get `NoneType` errors. **Fix**: Register both forms in `load_{module}()`: ```python env.create_supplier = create_supplier env.create_suppliers = create_supplier # xls2ui uses plural env.update_supplier = update_supplier env.update_suppliers = update_supplier env.delete_supplier = delete_supplier env.delete_suppliers = delete_supplier ``` **Symptom**: CRUD "add" button returns 500 with `function not found` — the wrapper dspy calls `create_suppliers` but only `create_supplier` was registered. **Pitfall: Subagent Delegation for Module Creation — ALWAYS Validate After** When using `delegate_task` to create modules in parallel, subagents **consistently** produce errors even when given correct spec instructions (observed 2/3 failure rate): 1. **File misplacement**: `init.py` and `__init__.py` placed at module root (`mymodule/init.py`) instead of package dir (`mymodule/mymodule/init.py`) 2. **Wrong model JSON format**: Uses `{"table":"...", "fields":{...}}` instead of `{"summary":[...], "fields":[...], "indexes":[...], "codes":[...]}` 3. **Wrong CRUD JSON format**: Uses `{"table":"...", "list":{...}}` instead of `{"tblname":"...", "params":{"browserfields":{...}, "editable":{...}}}` 4. **Hallucinated sqlor APIs**: Invents `sqlor.save/list/one/delete/insert/query` — these methods DO NOT EXIST. Only `sor.C/U/D/R/I/sqlExe` are valid. **Mandatory post-delegation validation** (see `references/bulk-module-creation-pattern.md` "Subagent Validation Checklist"): - Move misplaced init.py/__init__.py to package dir - Validate all model JSONs have `summary` key with array primary - Validate all CRUD JSONs have `tblname` and `params.editable` - Grep for hallucinated sqlor APIs and fix to sor.C/U/D pattern - Run dspy audit (no imports, no print, no uuid) - Run py_compile on all .py files **Pitfall: debug() in dspy does NOT include filename — always prefix manually** `debug()` calls in `.dspy` files log as `[sage][debug][:N]` — the framework injects dspy code into a `` context, so the file path is never shown. When tracing production issues across multiple dspy files, all debug output looks identical. **Fix**: Always prefix debug calls with the file name: ```python # WRONG — no way to tell which dspy this came from debug(f'{params_kw=}') # CORRECT — immediately identifiable in logs debug(f'product_category_create.dspy: START params_kw={dict(params_kw)}') debug(f'get_product_category.dspy: ns keys={list(ns.keys())}') ``` This is essential when debugging multi-step flows (e.g. add → refresh) where several dspy files execute in sequence. Each debug line becomes self-identifying. **Pitfall: DSPY API handlers must forward ALL client params — never hardcode dispatch fields** When a `.dspy` API endpoint acts as a task dispatcher (submitting to a longtasks worker or downstream service), it MUST forward the client's parameters. Hardcoding dispatch fields like `task_type` silently ignores client intent: ```python # WRONG — hardcodes task_type, ignores client's task_type payload = {'task_type': 'separate', 'task_id': task_id, 'audio_path': audio_path} await longtasks.submit_task(payload) # CORRECT — reads task_type from request, forwards all params task_type = params_kw.get('task_type', 'separate') payload = {'task_type': task_type, 'task_id': task_id, 'audio_path': audio_path} if params_kw.get('output_dir'): payload['output_dir'] = params_kw['output_dir'] await longtasks.submit_task(payload) ``` **Symptom**: Client sends `task_type: "separate_full"` but worker log shows `task_type: "separate"`. Worker always runs the default mode regardless of what client requested. Takes hours to trace because the worker code is correct — the DSPY is silently dropping the parameter. **Audit**: grep API DSPY files for hardcoded `'task_type'`, `'mode'`, or similar dispatch strings inside payload construction. **Pitfall: py_compile is INVALID for .dspy files** `.dspy` files are injected into an async function context at runtime — `return` at top level is correct. Running `py_compile` on `.dspy` files produces false positives (`'return' outside function`, `'await' outside function`). Only use `py_compile` for `.py` files (like `init.py`). For `.dspy` validation, use the dspy audit script (grep-based checks). **CRITICAL: dspy MUST use explicit `return` — ahserver wraps code in `async def`.** The dspy handler prepends `async def myfunc(request, **ns):` and awaits the function. Setting `result` as a bare expression without `return` causes the function to return `None`. See `references/dspy-execution-and-module-structure.md` for the full execution model, pre-loaded globals, Tree widget data format, and module symlink patterns. **Pitfall: Sage startup requires environment variables — cannot test locally without them** Sage's `app/sage.py` imports modules that read env vars at import time (e.g., `ALIPAY_PUB` for payment keys). Starting Sage locally without these vars causes `FileNotFoundError`. Use syntax checks (`py_compile` for .py files) and dspy audit scripts instead of full startup for local validation. **Pitfall: Raw SQL column names — ALWAYS verify against model.json, never guess** When writing raw SQL (`sor.sqlExe`) or ORM calls that reference table columns, ALWAYS verify the column names against the actual model definition (`models/{table}.json`), not from memory or similar tables. Guessing column names (e.g., `permcode` instead of `path`, `permname` instead of `name`) causes hard-to-debug runtime errors that require multiple fix attempts. **Check before writing SQL:** ```bash python3 -c "import json; cols=[f['name'] for f in json.load(open('models/table.json'))['fields']]; print(cols)" ``` **Common wrong guesses → correct names in Sage:** | Table | Wrong guess | Correct (from model) | |-------|-----------|---------------------| | permission | permcode, permname | path, name | | users | created_date | created_at | | organization | org_name | orgname | **Pitfall: `{{id}}` in uapi response templates resolves to Python built-in `id()`** In Sage uapi response templates, Jinja2 resolves `{{id}}` as Python's `id()` built-in function, not the upstream API's `id` field. This produces `` in the response instead of the actual task/request ID. **Fix**: Never use `{{id}}` in uapi response templates. Always map to a different field name: ```python # WRONG — {{id}} resolves to Python id() function '{"taskid":"{{id}}","status":"{{status}}"}' # CORRECT — map via request context or use a different variable '{"taskid":"{{taskid}}","status":"{{status}}"}' ``` **Affected**: Any uapi where the upstream response has a field called `id`. Sage's Jinja2 context silently shadows it with the Python built-in. **Pitfall: uapiio `input_fields` MUST be a JSON array for bricks.js compatibility** The bricks.js `LlmIO` widget calls `this.input_fields.forEach(...)`. If `input_fields` is a JSON object `{"field": {...}}` instead of a JSON array `[{"name":"field", ...}]`, the browser throws: ``` TypeError: this.input_fields.forEach is not a function ``` **Fix**: Always format uapiio `input_fields` as an array of objects: ```json // WRONG — object format, bricks.js can't iterate {"prompt":{"type":"string","required":true}} // CORRECT — array format with name/label/uitype [{"name":"prompt","label":"用户输入","uitype":"text","required":true}] ``` **Reference**: Use existing KTV uapiio records (e.g., `ktv_asr_transcribe_io`) as the canonical format reference. **Pitfall: `json.dumps()` in uapi data templates causes `data=None`** When a uapi data template uses `{{json.dumps(prompt, ensure_ascii=False)}}`, the Jinja2 rendering can fail silently, resulting in the HTTP request body being `None`. This logs as `body=None` in the uapi debug output. **Fix**: Use plain string interpolation `"{{prompt}}"` instead of `json.dumps()`. The model name should be hardcoded to the upstream service ID (e.g., `"model":"hy-image-v3.0"`), not passed through `{{model}}`. ```python # WRONG — json.dumps can render to None '{"model":"{{model}}","prompt":{{json.dumps(prompt,ensure_ascii=False)}}}' # CORRECT — plain string, hardcoded service model ID '{"model":"hy-image-v3.0","prompt":"{{prompt}}"}' ``` **Model ID sourcing**: The model ID sent to the upstream must be the provider's actual service ID. Discover actual IDs by querying the provider's `/v3/models` endpoint (ARK) or `/v1/models` endpoint (tokenhub), then hardcode them in the uapi data template. **Pitfall: `return` inside `async with` → silent NoneType in .dspy files** The ahserver framework wraps .dspy code in `async def myfunc(request, **ns):` and awaits it. Returning from inside an `async with db.sqlorContext(...) as sor:` block causes the function to return `None`, producing `return data type error, `. **Fix**: Collect all results inside the `async with` block, then `return` AFTER the block exits: ```python # WRONG — return inside async with → NoneType async with db.sqlorContext(dbname) as sor: recs = await sor.R('table') return json.dumps({'total': len(recs), 'rows': rows}) # CORRECT — collect inside, return outside rows = [] async with db.sqlorContext(dbname) as sor: recs = await sor.R('table') for r in (recs or []): rows.append({'id': r.id, 'name': r.name}) return {'total': len(rows), 'rows': rows} ``` **Symptom**: `Exception: /path/file.dspy return data type error, ` with no other traceback. Minimal test: `return {'ok': True}` inside async with also fails. **Pitfall: `sor.C()` silently drops records when `created_at` is missing** sqlor's `sor.C()` does NOT auto-set timestamp fields. If the model defines `created_at TIMESTAMP NOT NULL`, `sor.C` may succeed silently but the record never reaches the database. Always include `'created_at': curDateString()`: ```python ns = dict(params_kw) ns['id'] = params_kw.get('id', getID()) ns['created_at'] = curDateString() # REQUIRED — sor.C does not auto-set ns['org_id'] = (await get_userorgid()) or '0' async with db.sqlorContext(dbname) as sor: await sor.C('table_name', ns) ``` **Symptom**: dspy returns `{"success": true}` but `SELECT` returns zero rows. **Pitfall: Bash heredoc `\$` escapes when writing dspy files via terminal** When using `cat > file.dspy << 'EOF'` in bash, backslash sequences like `\${pid}\$` intended as sqlor placeholders get written literally as `\${pid}\$` instead of `${pid}$`. This causes sqlor to fail silently because the placeholder pattern is unrecognized. **Fix**: Never escape `$` in heredocs for dspy files. Write `${pid}$` as-is: ```bash # WRONG — heredoc writes literal backslash cat > file.dspy << 'EOF' sql = 'WHERE id=\${pid}\$' EOF # CORRECT — no escaping needed in single-quoted heredoc cat > file.dspy << 'EOF' sql = 'WHERE id=${pid}$' EOF ``` **Verification**: `grep 'project_id' file.dspy | cat -A` — look for `\$` (shows as `\$`) which means the backslash was written literally. Correct output shows `${pid}$` with no backslash. **Pitfall: Do NOT name module functions `get_module_dbname`** In `init.py`, do NOT define a function named `get_module_dbname()` — this name is already provided as a global by ServerEnv and is used by .dspy scripts. Registering it via `env.get_module_dbname = ...` will overwrite the global. If you need a module-specific dbname helper, name it with a private prefix like `_get_dbname()`. **Pitfall: Do NOT name module functions `get_module_dbname`** **Key Points:** - All functions that need to be accessible from .ui/.dspy files must be registered with ServerEnv - The `load_{modulename}()` function is called by Sage during module loading - After registration, functions can be called directly in .ui templates: `{% set data = get_data() %}` - This pattern is used by all standard modules (rbac, appbase, etc.) ### 2. Frontend Development - **Mandatory**: Use bricks-framework for all frontend interfaces - Store all .ui files as **pure JSON format** (NOT HTML/CSS) in wwwroot/ - See `references/bricks-ui-pitfalls.md` for id placement (widget level, not options), Button click event, Popup/Form patterns, and script actiontype Jinja2 limitations. - **Automatic routing**: Files in wwwroot/ are automatically accessible via `/{module_name}/filename.ext` - **No manual route registration needed**: The framework automatically serves .ui and .dspy files - .ui files contain component definitions with widgettype, options, subwidgets, and binds properties - Store CSS files in wwwroot/ for styling extensions - Store JavaScript files in wwwroot/ for custom functionality and registerFunction extensions - **ahserver auto-serves .css and .js files**: files placed in wwwroot/ are automatically discovered and injected by ahserver into the HTML response — **DO NOT** manually add `` or `