labull-framework 0.1.7__tar.gz → 0.1.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {labull_framework-0.1.7/labull_framework.egg-info → labull_framework-0.1.8}/PKG-INFO +1 -1
  2. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/magic_block/devkit.py +239 -6
  3. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/magic_block/export.py +7 -0
  4. {labull_framework-0.1.7 → labull_framework-0.1.8/labull_framework.egg-info}/PKG-INFO +1 -1
  5. {labull_framework-0.1.7 → labull_framework-0.1.8}/pyproject.toml +1 -1
  6. {labull_framework-0.1.7 → labull_framework-0.1.8}/LICENSE +0 -0
  7. {labull_framework-0.1.7 → labull_framework-0.1.8}/MANIFEST.in +0 -0
  8. {labull_framework-0.1.7 → labull_framework-0.1.8}/README-pypi.md +0 -0
  9. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/__init__.py +0 -0
  10. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/admin.py +0 -0
  11. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/apps.py +0 -0
  12. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/auth/__init__.py +0 -0
  13. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/auth/backend.py +0 -0
  14. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/auth/bootstrap.py +0 -0
  15. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/auth/sso.py +0 -0
  16. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/auth/views.py +0 -0
  17. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/canvas/__init__.py +0 -0
  18. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/canvas/data.py +0 -0
  19. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/canvas/gitinfo.py +0 -0
  20. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/canvas/static/canvas.css +0 -0
  21. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/canvas/static/canvas.js +0 -0
  22. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/canvas/static/page.html +0 -0
  23. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/canvas/views.py +0 -0
  24. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/env.py +0 -0
  25. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/jsonio.py +0 -0
  26. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/magic_block/__init__.py +0 -0
  27. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/magic_block/blockpackage.py +0 -0
  28. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/magic_block/deepcheck.py +0 -0
  29. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/magic_block/dispatch.py +0 -0
  30. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/magic_block/health.py +0 -0
  31. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/magic_block/registry.py +0 -0
  32. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/magic_block/sdk.py +0 -0
  33. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/magic_block/service.py +0 -0
  34. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/magic_block/validate.py +0 -0
  35. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/magic_block/views.py +0 -0
  36. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/management/__init__.py +0 -0
  37. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/management/commands/__init__.py +0 -0
  38. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/management/commands/labull_contract.py +0 -0
  39. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/migrations/0001_initial.py +0 -0
  40. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/migrations/__init__.py +0 -0
  41. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/models.py +0 -0
  42. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/settings.py +0 -0
  43. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework/urls.py +0 -0
  44. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework.egg-info/SOURCES.txt +0 -0
  45. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework.egg-info/dependency_links.txt +0 -0
  46. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework.egg-info/requires.txt +0 -0
  47. {labull_framework-0.1.7 → labull_framework-0.1.8}/labull_framework.egg-info/top_level.txt +0 -0
  48. {labull_framework-0.1.7 → labull_framework-0.1.8}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 0.1.7
3
+ Version: 0.1.8
4
4
  Summary: Labull Framework: a pluggable Django app with Authing OIDC login and magic blocks
5
5
  Author: Labull Framework
6
6
  License-Expression: MIT
@@ -47,6 +47,13 @@ PAGES_LOCK = 'pages.lock.ts'
47
47
  #: ⚠️ 这两个名字与 `frontend/magic-block/src/theme.ts` 的 `THEME_FILE` / `CONTROLS_FILE` **逐字一致**。
48
48
  THEME = 'labull.css'
49
49
  CONTROLS = 'CONTROLS.lock.html'
50
+ #: 面向**编码 agent**(Claude Code / Copilot / Cursor …)的那一份:哪些绝不许做、权限住在哪。
51
+ #: 🔴 为什么单独一份:`AGENTS.md` 是那几个工具的**约定文件名**,它们会优先读它。
52
+ #: 而前两次真出过的事故都不是"不会写代码",是**没人告诉过边界**:
53
+ #: · 自己发明一套权限码,再拿 Django 的 `auth_permission` 当容器装(那块清单里根本没有那张表);
54
+ #: · 把"拆文件"理解成块内互相 import。
55
+ #: ⚠️ 与 `GUIDE` 分工:GUIDE 讲"怎么写",这一份讲"**不许怎么做、以及拿不准时先查哪里**"。
56
+ AGENTS = 'AGENTS.lock.md'
50
57
  #: 后端依赖的那份清单(**项目自己的 `requirements.txt` 原样抄一份**):
51
58
  #: 块后端跑在项目的解释器里,作者要 import 什么就得先装什么 —— 给他一份可照抄的参考。
52
59
  #: 文件本身是 `requirements.txt` 的逐字副本(开头加了一行说明注释),可以直接 `pip install -r`。
@@ -276,6 +283,89 @@ def _relation_lines(table: dict[str, Any]) -> list[str]:
276
283
  return out
277
284
 
278
285
 
286
+ #: 哪些 app 算"平台自己的"(与项目业务分开说:它只管登录与块本身)。
287
+ _PLATFORM_APPS: frozenset[str] = frozenset({'labull_framework'})
288
+ #: 哪些 app 算 **Django 自带的元数据**(模型级 CRUD / admin / 会话 / 迁移)—— 一律点名"别当业务口径"。
289
+ #: ⚠️ 名字是 app_label,`django.contrib.auth` ⇒ `auth`。
290
+ _DJANGO_APPS: frozenset[str] = frozenset({
291
+ 'auth', 'admin', 'contenttypes', 'sessions', 'messages', 'staticfiles', 'sites',
292
+ 'redirects', 'flatpages', 'humanize', 'postgres', 'gis', 'admindocs',
293
+ })
294
+ #: 这些前缀太泛,**不算**"权限样字段组"的前缀(`is_active` / `can_edit` 这种到处都是)。
295
+ _GENERIC_PREFIXES: frozenset[str] = frozenset({
296
+ 'is', 'can', 'has', 'allow', 'enable', 'enabled', 'active', 'visible', 'deleted', 'readonly',
297
+ 'public', 'private', 'draft', 'locked', 'sent', 'done',
298
+ })
299
+ #: "授权位"。出现在 help_text / verbose_name 里就说明这一列是**给谁用的**,不是数据。
300
+ _PERMISSION_HINTS: tuple[str, ...] = ('仅', '只允许', 'only ', 'permission', '权限', '授权')
301
+
302
+
303
+ def _source_of(module: str) -> str:
304
+ """一个 app_label 属于哪一类来源:`business` / `platform` / `django`。"""
305
+ label = str(module or '')
306
+ if label in _DJANGO_APPS or label.startswith('django.'):
307
+ return 'django'
308
+ if label in _PLATFORM_APPS:
309
+ return 'platform'
310
+ return 'business'
311
+
312
+
313
+ def _field_prefix(name: str) -> str:
314
+ """字段名的第一段(`<前缀>_is_x` ⇒ `<前缀>`);没有下划线就不成组。"""
315
+ text = str(name or '')
316
+ head, sep, _rest = text.partition('_')
317
+ return head if sep and head else ''
318
+
319
+
320
+ def _permission_groups(fields: list[dict[str, Any]]) -> list[tuple[str, list[str]]]:
321
+ """这份模型里那几组"**权限样字段**":`[(前缀, [字段名, …])]`。
322
+
323
+ 🔴 判据是**通用的两条**(不认业务词):
324
+ ① 同一前缀的**布尔列成组出现**(≥2 个)—— 单独一个布尔列不算(`is_active` 到处都是);
325
+ ② 这一组里有字段的备注带限定语(`(仅XX)` / `只允许` / `permission`)——
326
+ 那是"**岗位内的授权位**",不是数据列。
327
+ 前缀太泛(`is_` / `can_` / `has_` …)的一律不算组:那种名字满世界都是。
328
+ 🔴 为什么值得单独列出来:需求里说"权限"时,开发者最容易把这类布尔位当成"业务数据"去写条件,
329
+ 而它们真正的语义是"**这个人在这个岗位上能不能干这件事**"——得跟组织/岗位一起读。
330
+ """
331
+ booleans = [f for f in fields if str(f.get('kind') or '') in ('boolean',) and f.get('name')]
332
+ buckets: dict[str, list[dict[str, Any]]] = {}
333
+ for item in booleans:
334
+ prefix = _field_prefix(str(item.get('name')))
335
+ if not prefix or prefix in _GENERIC_PREFIXES:
336
+ continue
337
+ buckets.setdefault(prefix, []).append(item)
338
+ out: list[tuple[str, list[str]]] = []
339
+ for prefix, items in buckets.items():
340
+ if len(items) < 2:
341
+ continue
342
+ hinted = any(
343
+ any(mark in f'{item.get("brief") or ""}{item.get("briefLine") or ""}' for mark in _PERMISSION_HINTS)
344
+ or '_can_' in str(item.get('name'))
345
+ for item in items
346
+ )
347
+ if not hinted:
348
+ continue
349
+ out.append((prefix, [str(item.get('name')) for item in items]))
350
+ # 组多的排前面,同数量按前缀排序(结果稳定,导出可复现)。
351
+ out.sort(key=lambda pair: (-len(pair[1]), pair[0]))
352
+ return out
353
+
354
+
355
+ def _permission_index(table: dict[str, Any]) -> list[str]:
356
+ """一个模型的"权限样字段组"索引(没有就回空)。"""
357
+ fields = [field for field in (table.get('fields') or []) if isinstance(field, dict)]
358
+ groups = _permission_groups(fields)
359
+ if not groups:
360
+ return []
361
+ total = sum(len(names) for _prefix, names in groups)
362
+ parts = ' / '.join(f'{prefix}_*({len(names)})' for prefix, names in groups)
363
+ head = f' # 权限样字段组:{parts} —— 合计 {total} 个布尔位'
364
+ detail = (' # (授权位,不是数据:"某个岗位上能不能干这件事"的开关,要和岗位/组织一起读;'
365
+ '各组的字段行就在下面)')
366
+ return [head, detail]
367
+
368
+
279
369
  def _table_block(table: dict[str, Any]) -> list[str]:
280
370
  """一张表那一块(类名 / 备注 / 真实 import / 枚举类 / 字段 / 关联)。"""
281
371
  name = str(table.get('name') or '')
@@ -285,7 +375,10 @@ def _table_block(table: dict[str, Any]) -> list[str]:
285
375
  brief = _brief(table.get('brief') or table.get('briefLine') or table.get('verbose'))
286
376
  where = f'表 {_flat(table.get("table"))}' + (f' · 模块 {module}' if module else '')
287
377
  out = [f'class {name}:', f' """{brief}({where})"""' if brief else f' """{where}"""']
288
- if module:
378
+ # 🔴 那张"一般这样 import"的提示**只对业务表与平台表给**:Django 自带的那几张(`auth` / `admin` …)
379
+ # 拼出来的路径是**错的**(`Permission` 在 `django.contrib.auth.models`,不在 `auth.models`),
380
+ # 而清单里给一条错的 import 只会把人推向"照抄一句、然后自己补权限逻辑"那条路。
381
+ if module and _source_of(module) != 'django':
289
382
  out.append(f' # 一般这样 import:from {module}.models import {name}')
290
383
  enums = table.get('enums') or {}
291
384
  fields = [field for field in (table.get('fields') or []) if isinstance(field, dict)]
@@ -293,6 +386,10 @@ def _table_block(table: dict[str, Any]) -> list[str]:
293
386
  # 而成员表在 table 那一层。传进去比让每个字段自己去找一份可靠(也就不会有第二份匹配逻辑)。
294
387
  used = {str(field.get('enumRef') or '') for field in fields if field.get('enumRef')}
295
388
  out.extend(_enum_classes(table, used))
389
+ # 🔴 "**权限样字段组**"索引:那些成组出现的布尔位(`<前缀>_is_*` / `<前缀>_can_*`
390
+ # + 备注里的 `(仅XX)`)是**授权位**,不是数据列。索引摆在这一块最上面,
391
+ # 就是为了让人(和 agent)在逐行读字段之前先看见:"这张表的权限在哪儿"。
392
+ out.extend(_permission_index(table))
296
393
  for field in fields:
297
394
  line = _field_lines({**field, '_enums': enums})
298
395
  if line:
@@ -316,7 +413,7 @@ EXAMPLE_DIR = 'example'
316
413
 
317
414
  #: 例子包里那几份文件(名字顺序就是铺出来给人看的顺序)。
318
415
  EXAMPLE_FILES: tuple[tuple[str, str], ...] = (
319
- ('README.md', '# 例子:身份证 OCR → 客户档案\n\n这个目录是开发包里的**示例**,不是某个块的代码。它演示一条完整的活:\n**前端选中一张证件照 → 交给块的后端 → 后端调阿里云 OCR → 结果回前端确认 → 存进客户档案**。\n\n- 这个目录里**混着两类东西**,用的时候要分开看:\n - **能当块用的那几份**(前端拆了 4 份、后端拆了 3 份,加清单与这一份说明):\n `index.open.html` / `app.css` / `app.js` / `ui.js` / `magic-block.open.yaml` /\n `backend_open.py` / `ocr.py` / `customer.py` / 这一份 `README.md`;\n - **与开发目录顶层同名的那几份生成物**(`backend.lock.pyi` / `GUIDE.lock.md` / `README.lock.md` /\n `requirements.lock.txt`,以及前端补的 `pages.lock.ts` / `labull.css` / `CONTROLS.lock.html`):\n **只是给你查的参考资料,不是这个块的一部分**(读回时它们会被跳过)。\n- 想用它:把那**几份**(上面前一类)移进 `workspace/<可读名>-<你自己的块 uuid>/`,\n 把清单里的 `id` 换成那个 uuid,然后回管理台「读回改动」→ 勾上它 → 保存。\n **什么都不用先装**(除了下面那三行配置)。\n- 移过去之后、**保存之前**,在开发目录里跑一句自检:\n `node smoke.lock.mjs <你那个文件夹名>` —— 清单、前端引用、后端规矩它都会过一遍。\n- 它对应 `orders` 那张客户表的四列:`id_number` / `gender` / `birthday` / `address`\n (`Customer` 上那四列本来就是给身份证 OCR 留的落点)。\n\n## 〇、这个例子**拆成了几个文件**(可以照这么写,但有两条规矩)\n\n| 文件 | 是什么 |\n|---|---|\n| `index.open.html` | 入口:只有结构,样式与脚本用 `link` / `script` 引进来 |\n| `app.css` | 这个块的样式 |\n| `ui.js` | 界面那点事(取元素、显示状态、填/读表单)—— 普通脚本,挂在 `window.ocrUi` 上 |\n| `app.js` | 主逻辑:选图 → 调后端 → 存库 |\n| `backend_open.py` | 后端**路由层**:两个 `@MagicBackendAPI` 方法 |\n| `ocr.py` | **参考实现**:怎么调阿里云 SDK(不产生路由,不参与运行) |\n| `customer.py` | **参考实现**:怎么写进客户表(同上) |\n\n- **前端**:随便拆。宿主拼预览时会把 `<script src>` / `<link href>` 指向的**包内文件**内联进同一个\n 文档,所以引用写法与普通网页一样。⚠️ 但**入口引用的文件一个都不能少**(少一个就是一整块白屏)。\n ⚠️ 块里**没有模块系统**:不能写 `import` / `export`,各文件之间靠**全局名字**协作(例子用 `window.ocrUi`)。\n- **后端**:每个 `.py` 都是**独立入口**(平台逐个加载,与 `backend_open.py` 待遇完全一样)⇒\n **文件之间不能互相引用**(`from ocr import ...` 会被保存时的判据与 `smoke.lock.mjs` 拦下)。\n 想分文件就分,但每个文件要能独立站住;公共逻辑要么各写一份,要么放进**项目**里的模块\n (那是普通 Python,随便 import)。`ocr.py` / `customer.py` 因此是"可以整段复制进路由"的参考,\n 而不是被 import 的库。\n\n\n## 一、要配的三项(缺一项块会点名说缺哪个)\n\n块**只能**读带 `BLOCK_` 前缀的配置(前缀会被剥掉,所以块里写 `ctx.config[\'OCR_ENDPOINT\']`):\n\n```dotenv\nBLOCK_OCR_ENDPOINT=ocr-api.cn-hangzhou.aliyuncs.com\nBLOCK_OCR_ACCESS_KEY_ID=<RAM 用户的 AccessKey ID>\nBLOCK_OCR_ACCESS_KEY_SECRET=<RAM 用户的 AccessKey Secret>\n```\n\n- 这三项加在**项目**的 `.env` 里(不是块的文件夹里),改完要重启服务。\n- 那个 RAM 用户要**有 OCR 的权限**,并且阿里云控制台上要**开通文字识别服务** —— 没开通时\n 报的是 `Forbidden` 之类,看着像"密钥不对",其实是服务没开。\n- 块里**永远拿不到**别的环境变量:只有 `BLOCK_` 这一族会进 `ctx.config`。\n\n依赖:后端要用阿里云 OCR 的官方 Python SDK(`pip install alibabacloud_ocr_api20210707`)。\n\n## 二、为什么用官方 SDK,而不是自己拼签名\n\n这一条是**实测**出来的,值得记一笔:手写 RPC 签名(HMAC-SHA1 + percent-编码)我可以让它\n**通过鉴权**,但**图片传不进去** —— 同一账号、同一张图:\n\n| 调用方式 | 服务端的回答 | 意思 |\n|---|---|---|\n| 手写签名 + base64 字符串 | `imageUrlOrBodyEmpty` | 服务端**根本没读到图** |\n| 官方 SDK,`body=<字节>` | `illegalImageContent` | 读到了,只是那张图不是身份证 |\n\n差异在请求类里:`RecognizeIdcardRequest.body` 是**小写**名字、而且是**二进制流**,不是 base64 字符串。\n自己拼那一套还要同时算对"签名用的编码"与"正文用的编码"两份(它们**不是同一份**,我在这上面来回错了三次),\n任何一个参数改一下都可能悄悄失效 —— 不值当。**照 SDK 的形状写**。\n\n## 三、两个刻意的设计\n\n1. **探针不会真的去打一次 OCR。** 保存与体检时每个 handler 会被真的跑一遍(默认空参数),\n 所以 `recognize()` 的第一句就是"没给我图片 ⇒ BadRequest"。少了这一句,每存一次块都会白打一次\n OCR,打不通还会把块判成"体检不通过"。\n2. **识别与落库分成两个接口。** `recognize()` 只读、`save_customer()` 才写(而且声明了权限)。\n OCR 会认错字,让人**看一眼再存**比"识别完直接落库"稳。\n\n## 四、改造成你自己的\n\n| 想改什么 | 改哪里 |\n|---|---|\n| 换成别的证件 / 别的 OCR 接口 | `backend_open.py` 的 `recognize_idcard()` 与 `parse_idcard()` |\n| 落进别的表 / 别的列 | `save_customer()` 里那张 `Customer` 与那几个键 |\n| 图片不走 base64(比如先传对象存储) | 前端把上传拿到的 URL 传过来,后端改用 SDK 的 `url=...` 参数 |\n| 出生日期要真的当日期用 | `birthday` 现在是"OCR 怎么念就怎么存"的文本;转 `DateField` 要自己处理各种念法 |\n'),
416
+ ('README.md', '# 例子:身份证 OCR → 客户档案\n\n这个目录是开发包里的**示例**,不是某个块的代码。它演示一条完整的活:\n**前端选中一张证件照 → 交给块的后端 → 后端调阿里云 OCR → 结果回前端确认 → 存进客户档案**。\n\n- 这个目录里**混着两类东西**,用的时候要分开看:\n - **能当块用的那几份**(前端拆了 4 份、后端拆了 3 份,加清单与这一份说明):\n `index.open.html` / `app.css` / `app.js` / `ui.js` / `magic-block.open.yaml` /\n `backend_open.py` / `ocr.py` / `customer.py` / 这一份 `README.md`;\n - **与开发目录顶层同名的那几份生成物**(`backend.lock.pyi` / `GUIDE.lock.md` / `README.lock.md` /\n `requirements.lock.txt`,以及前端补的 `pages.lock.ts` / `labull.css` / `CONTROLS.lock.html`):\n **只是给你查的参考资料,不是这个块的一部分**(读回时它们会被跳过)。\n- 想用它:把那**几份**(上面前一类)移进 `workspace/<可读名>-<你自己的块 uuid>/`,\n 把清单里的 `id` 换成那个 uuid,然后回管理台「读回改动」→ 勾上它 → 保存。\n **什么都不用先装**(除了下面那三行配置)。\n- 移过去之后、**保存之前**,在开发目录里跑一句自检:\n `node smoke.lock.mjs <你那个文件夹名>` —— 清单、前端引用、后端规矩它都会过一遍。\n- 🔴 **例子里的表名与列名只是它自己的场景**(它对应示例项目里"客户档案"那张表):\n 照抄结构,不要照抄名字 —— 你的项目里是哪张表、哪几列,查 `backend.lock.pyi`。\n\n## 〇、这个例子**拆成了几个文件**(可以照这么写,但有两条规矩)\n\n| 文件 | 是什么 |\n|---|---|\n| `index.open.html` | 入口:只有结构,样式与脚本用 `link` / `script` 引进来 |\n| `app.css` | 这个块的样式 |\n| `ui.js` | 界面那点事(取元素、显示状态、填/读表单)—— 普通脚本,挂在 `window.ocrUi` 上 |\n| `app.js` | 主逻辑:选图 → 调后端 → 存库 |\n| `backend_open.py` | 后端**路由层**:两个 `@MagicBackendAPI` 方法 |\n| `ocr.py` | **参考实现**:怎么调阿里云 SDK(不产生路由,不参与运行) |\n| `customer.py` | **参考实现**:怎么写进客户表(同上) |\n\n- **前端**:随便拆。宿主拼预览时会把 `<script src>` / `<link href>` 指向的**包内文件**内联进同一个\n 文档,所以引用写法与普通网页一样。⚠️ 但**入口引用的文件一个都不能少**(少一个就是一整块白屏)。\n ⚠️ 块里**没有模块系统**:不能写 `import` / `export`,各文件之间靠**全局名字**协作(例子用 `window.ocrUi`)。\n- **后端**:每个 `.py` 都是**独立入口**(平台逐个加载,与 `backend_open.py` 待遇完全一样)⇒\n **文件之间不能互相引用**(`from ocr import ...` 会被保存时的判据与 `smoke.lock.mjs` 拦下)。\n 想分文件就分,但每个文件要能独立站住;公共逻辑要么各写一份,要么放进**项目**里的模块\n (那是普通 Python,随便 import)。`ocr.py` / `customer.py` 因此是"可以整段复制进路由"的参考,\n 而不是被 import 的库。\n\n\n## 一、要配的三项(缺一项块会点名说缺哪个)\n\n块**只能**读带 `BLOCK_` 前缀的配置(前缀会被剥掉,所以块里写 `ctx.config[\'OCR_ENDPOINT\']`):\n\n```dotenv\nBLOCK_OCR_ENDPOINT=ocr-api.cn-hangzhou.aliyuncs.com\nBLOCK_OCR_ACCESS_KEY_ID=<RAM 用户的 AccessKey ID>\nBLOCK_OCR_ACCESS_KEY_SECRET=<RAM 用户的 AccessKey Secret>\n```\n\n- 这三项加在**项目**的 `.env` 里(不是块的文件夹里),改完要重启服务。\n- 那个 RAM 用户要**有 OCR 的权限**,并且阿里云控制台上要**开通文字识别服务** —— 没开通时\n 报的是 `Forbidden` 之类,看着像"密钥不对",其实是服务没开。\n- 块里**永远拿不到**别的环境变量:只有 `BLOCK_` 这一族会进 `ctx.config`。\n\n依赖:后端要用阿里云 OCR 的官方 Python SDK(`pip install alibabacloud_ocr_api20210707`)。\n\n## 二、为什么用官方 SDK,而不是自己拼签名\n\n这一条是**实测**出来的,值得记一笔:手写 RPC 签名(HMAC-SHA1 + percent-编码)我可以让它\n**通过鉴权**,但**图片传不进去** —— 同一账号、同一张图:\n\n| 调用方式 | 服务端的回答 | 意思 |\n|---|---|---|\n| 手写签名 + base64 字符串 | `imageUrlOrBodyEmpty` | 服务端**根本没读到图** |\n| 官方 SDK,`body=<字节>` | `illegalImageContent` | 读到了,只是那张图不是身份证 |\n\n差异在请求类里:`RecognizeIdcardRequest.body` 是**小写**名字、而且是**二进制流**,不是 base64 字符串。\n自己拼那一套还要同时算对"签名用的编码"与"正文用的编码"两份(它们**不是同一份**,我在这上面来回错了三次),\n任何一个参数改一下都可能悄悄失效 —— 不值当。**照 SDK 的形状写**。\n\n## 三、两个刻意的设计\n\n1. **探针不会真的去打一次 OCR。** 保存与体检时每个 handler 会被真的跑一遍(默认空参数),\n 所以 `recognize()` 的第一句就是"没给我图片 ⇒ BadRequest"。少了这一句,每存一次块都会白打一次\n OCR,打不通还会把块判成"体检不通过"。\n2. **识别与落库分成两个接口。** `recognize()` 只读、`save_customer()` 才写(而且声明了权限)。\n OCR 会认错字,让人**看一眼再存**比"识别完直接落库"稳。\n\n## 四、改造成你自己的\n\n| 想改什么 | 改哪里 |\n|---|---|\n| 换成别的证件 / 别的 OCR 接口 | `backend_open.py` 的 `recognize_idcard()` 与 `parse_idcard()` |\n| 落进别的表 / 别的列 | `save_customer()` 里那张 `Customer` 与那几个键 |\n| 图片不走 base64(比如先传对象存储) | 前端把上传拿到的 URL 传过来,后端改用 SDK 的 `url=...` 参数 |\n| 出生日期要真的当日期用 | `birthday` 现在是"OCR 怎么念就怎么存"的文本;转 `DateField` 要自己处理各种念法 |\n'),
320
417
  ('index.open.html', '<!doctype html>\n<!-- 身份证 OCR 案例块的前端 —— **入口**(样式与逻辑在同目录的 app.css / app.js / ui.js 里)。\n 🔴 这个包**拆成了几个文件**,这在前端是完全可以的:宿主拼预览时会把\n `<script src>` / `<link href>` 指向的**包内文件**内联进同一个文档(见 `preview.ts`),\n 所以引用写法与普通网页一样。\n 🔴 但**公共文件不能丢**:入口里引用的每一个文件都必须在包里 —— 少一个就是一整块白屏\n (`smoke.mjs` 会把这条查出来)。\n 🔴 这块 iframe 是不透明源的(没有 allow-same-origin)⇒ 拿不到宿主的登录态、读不到 cookie。\n 要当前用户 / 跳页 / 调自己的后端,一律走宿主注入的那座桥(`magicBlock` / `magicBackendApi`)。 -->\n<html lang="zh">\n<head>\n <meta charset="utf-8">\n <title>身份证 OCR 建档</title>\n <link rel="stylesheet" href="app.css">\n</head>\n<body>\n <p class="muted" id="who">正在确认身份…</p>\n\n <fieldset>\n <legend>① 选一张身份证正面</legend>\n <input type="file" id="shot" accept="image/*">\n <img id="thumb" alt="" hidden>\n <button id="run" disabled>识别</button>\n <span class="muted" id="stage"></span>\n </fieldset>\n\n <fieldset id="result" hidden>\n <legend>② 识别结果(**看一眼再存**)</legend>\n <p><label>姓名</label><input type="text" id="f-name"></p>\n <p><label>身份证号</label><input type="text" id="f-id"></p>\n <p><label>性别</label><input type="text" id="f-sex"></p>\n <p><label>出生</label><input type="text" id="f-birth"></p>\n <p><label>住址</label><input type="text" id="f-addr"></p>\n <button id="save">存进客户档案</button>\n <span class="muted" id="saved"></span>\n </fieldset>\n\n <p class="bad" id="error"></p>\n\n <script src="ui.js"></script>\n <script src="app.js"></script>\n</body>\n</html>\n'),
321
418
  ('app.css', '/* 身份证 OCR 案例块的样式(宿主会把这份内联进 iframe)。\n 颜色与间距用**主题给的 token**(`--sp-*`)比自己定一套更稳:宿主本来就会把 labull 主题\n 注入每个块,用 token 就能跟着应用换肤走。 */\n\nbody { margin: 0; padding: 12px; font: 14px/1.7 system-ui, sans-serif; color: #1f2937; }\n\nfieldset { margin: 0 0 10px; padding: 8px 10px; border: 1px solid #e5e7eb; border-radius: 8px; }\nlegend { padding: 0 4px; color: #6b7280; }\n\nlabel { display: inline-block; min-width: 4.5em; color: #6b7280; }\ninput[type=text] { width: min(420px, 70vw); padding: 3px 6px; border: 1px solid #d1d5db; border-radius: 6px; }\n\nbutton { padding: 3px 10px; border: 1px solid #d1d5db; border-radius: 6px; background: #fff; cursor: pointer; }\nbutton[disabled] { opacity: .5; cursor: default; }\n\n.muted { color: #6b7280; }\n.bad { color: #b91c1c; }\nimg { max-height: 90px; border-radius: 6px; outline: 1px solid #e5e7eb; }\n\n/* 🔴 块自己**不许出滚动条**:滚的是标签页那一层(`html, body { overflow: hidden }` 由宿主注入)。 */\n'),
322
419
  ('app.js', "/* 这个块的主逻辑:选图 → 调 OCR → 存进客户档案。\n *\n * 🔴 桥有两种用法都在这里:\n * · `magicBackendApi.<类>.<方法>` → 打到你自己的 `backend_open.py`(路由名 = 类名.方法名)\n * · `magicBlock.getMe()` → 当前用户(认人只认它,别从请求参数里取)\n * 🔴 界面那点事在 `ui.js` 里(同目录、由入口 `index.open.html` 一起引进来)。\n * 块里**没有模块系统**:普通 `<script>` 之间靠全局名字协作(`window.ocrUi`)。\n */\n\n(function () {\n var ui = window.ocrUi\n\n /** 把选中文件读成 base64(**不带** `data:image/png;base64,` 前缀:后端要的是能 b64decode 的那部分)。 */\n function readAsBase64(file) {\n return new Promise(function (resolve, reject) {\n var reader = new FileReader()\n reader.onerror = function () { reject(new Error('这个文件读不出来')) }\n reader.onload = function () {\n resolve(String(reader.result).split(',')[1] || '')\n }\n reader.readAsDataURL(file)\n })\n }\n\n var picked = ''\n\n document.getElementById('shot').onchange = function (event) {\n var file = event.target.files && event.target.files[0]\n ui.resetResult()\n if (!file) {\n picked = ''\n ui.busy('run', true)\n return\n }\n readAsBase64(file)\n .then(function (base64) {\n picked = base64\n ui.setPicked('data:image/*;base64,' + base64)\n ui.say('选好了,点「识别」')\n })\n .catch(function (error) { ui.say(error.message, true) })\n }\n\n document.getElementById('run').onclick = function () {\n ui.busy('run', true, '识别中…')\n ui.say('识别中…(服务端在调 OCR)')\n magicBackendApi.IdCardOcrBlock.recognize({ image: picked })\n .then(function (data) {\n ui.busy('run', false, '识别')\n ui.fill(data.fields)\n ui.say('识别完了:确认无误再存(识别可能出错,所以不自动落库)')\n })\n .catch(function (error) {\n /* 失败是 rejected Promise,`message` 就是后端抛的中文原话。 */\n ui.busy('run', false, '识别')\n ui.say(error.message, true)\n })\n }\n\n document.getElementById('save').onclick = function () {\n ui.busy('save', true, '保存中…')\n magicBackendApi.IdCardOcrBlock.save_customer({ fields: ui.read() })\n .then(function (data) {\n ui.busy('save', false, '存进客户档案')\n ui.setSaved(data.created ? ('已新建档案 #' + data.id) : ('已更新档案 #' + data.id))\n })\n .catch(function (error) {\n ui.busy('save', false, '存进客户档案')\n ui.say(error.message, true)\n })\n }\n\n magicBlock.getMe()\n .then(function (me) { ui.setWho(me) })\n .catch(function (error) { ui.say(error.message, true) })\n})()\n"),
@@ -355,7 +452,7 @@ def example_files(parts: dict[str, str] | None = None) -> dict[str, str]:
355
452
  """
356
453
  given = parts or {}
357
454
  out: dict[str, str] = {}
358
- for name in (README, GUIDE, BACKEND_LOCK, REQUIREMENTS, CONTROLS, THEME):
455
+ for name in (README, GUIDE, AGENTS, BACKEND_LOCK, REQUIREMENTS, CONTROLS, THEME):
359
456
  if name in given:
360
457
  out[name] = given[name]
361
458
  # ⚠️ `PAGES_LOCK` 也在这里:它是前端的路由表算出来的(后端给不出),
@@ -463,11 +560,145 @@ def backend_lock_pyi(
463
560
  )
464
561
  if not tables:
465
562
  lines.append('# (这次导出没拿到模型形状:没有任何 app 注册模型,或者模型那一层没起来)')
563
+ lines.append('#')
564
+ # 🔴 **按来源分组**(用户要求,也是防"拿 Django 自带的表当业务口径"最直接的一招):
565
+ # 同一个清单里混着三类表,而它们**能不能当业务用**完全不同。
566
+ # Django 自带的那几张级联进来时单独一组、并点名"不许当业务口径"。
567
+ groups: list[tuple[str, str, list[dict[str, Any]]]] = [
568
+ ('business', '① 你自己的业务表(**需求里的"角色 / 权限 / 状态"只可能住在这些表的列上**)', []),
569
+ ('platform', '② 平台表(登录与块本身 —— **不是业务**:要块/用户走 `ctx`,别直接查)', []),
570
+ ('django', '③ ❌ Django 自带的元数据(模型级 CRUD / admin 用)—— **别拿来当业务口径**', []),
571
+ ]
466
572
  for table in tables or []:
467
- lines.extend(_table_block(table))
573
+ which = _source_of(str(table.get('module')))
574
+ for kind, _title, bucket in groups:
575
+ if kind == which:
576
+ bucket.append(table)
577
+ break
578
+ for index, (kind, title, bucket) in enumerate(groups, start=1):
579
+ if not bucket:
580
+ continue
581
+ lines.append(f'# ── 5.{index} {title} ──')
582
+ if kind == 'django':
583
+ lines.extend([
584
+ '# 这些表是"某张表能不能增删改查"(add_/change_/delete_/view_),给 admin 用的,',
585
+ '# **不是业务权限目录**。拿它当"权限"是最常见的一类错:看着几十条、一条业务含义都没有。',
586
+ '# ⚠️ 正常情况下这里应该是空的 —— 它们**本来就不该进清单**;出现说明项目直接用了',
587
+ '# `django.contrib.auth` 的模型,那属于业务越界(见同目录 `' + AGENTS + '` 第 1 条)。',
588
+ ])
589
+ for table in bucket:
590
+ lines.extend(_table_block(table))
468
591
  return '\n'.join(lines).rstrip() + '\n'
469
592
 
470
593
 
594
+ def dev_agents(tables: list[dict[str, Any]] | None = None) -> str:
595
+ """`AGENTS.lock.md` 的正文:**面向编码 agent 的契约**(不许做什么 / 拿不准查哪里)。
596
+
597
+ 🔴 这一份只写"**判据**",不写"风格":它列的每条都是**真的会出错**的那种
598
+ (真出过两次:自造权限码装进 `auth_permission`;把拆文件写成块内互相 import)。
599
+ 🔴 表名与模型名**从形状算出来**(不手抄):项目里有哪些业务表、平台表是哪两张、
600
+ Django 自带的那几张为什么不在清单里 —— 这些都要在**这个项目**的语境下说清。
601
+ """
602
+ business = [t for t in (tables or []) if _source_of(str(t.get('module'))) == 'business']
603
+ platform = [t for t in (tables or []) if _source_of(str(t.get('module'))) == 'platform']
604
+ by_module: dict[str, list[str]] = {}
605
+ for table in business:
606
+ by_module.setdefault(str(table.get('module')), []).append(str(table.get('name')))
607
+
608
+ lines = [
609
+ '# AGENTS —— 在这个项目里写 magic block 的规矩',
610
+ '',
611
+ '> 这份文件是**判据**,不是风格建议:底下每一条都对应一类真出过的事故。',
612
+ '> 与 `GUIDE.lock.md` 分工:GUIDE 讲"怎么写",这一份讲"**不许怎么做、拿不准先查哪里**"。',
613
+ '',
614
+ '## 一、十条硬规矩(第一条最常被违反)',
615
+ '',
616
+ '1. **不许拿 Django 自带的表当业务口径。** 具体是这些(以及任何 `django.contrib.*` 的表):',
617
+ ' `auth_permission` / `auth_group` / `auth_group_permissions` / `auth_user_groups` /',
618
+ ' `auth_user_user_permissions` / `django_content_type` / `django_admin_log` /',
619
+ ' `django_migrations` / `django_session`。',
620
+ ' 🔴 它们是**模型级 CRUD**(某张表能不能增删改查),给 admin 用的 —— **不是业务权限目录**。',
621
+ ' 🔴 它们**根本不在 `backend.lock.pyi` 里**(清单就是权威名单)。要碰它们只能自己',
622
+ ' `from django.contrib.auth.models import Permission` —— 那一步就是**越界**,不要写。',
623
+ '2. **不许自己发明权限码、角色、状态码。** 需求让你"加权限",先问:',
624
+ ' ① 表上是不是已经有"权限样字段组"?—— 看 `backend.lock.pyi` 里每个模型上面那一行索引',
625
+ ' (形如 `# 权限样字段组:<前缀>_*(N) / <前缀>_*(M) —— 合计 K 个布尔位`);',
626
+ ' ② "谁在什么岗位上"这类信息住在哪张表上 —— 先在 3.1 那批业务表里找一轮',
627
+ ' (名字或备注像"组织 / 岗位 / 成员 / 角色"的那种)。',
628
+ ' 先读**已有的列**,再写代码。**没有的列就是没有** —— 不要用 `auth_permission` 去补。',
629
+ '3. **块只能 import 这个项目里能 import 的东西。** 拿不准哪个模型能用、字段叫什么、',
630
+ ' 是不是枚举:查 `backend.lock.pyi`(**只有里面的东西能用**),别凭印象写。',
631
+ '4. **每个 handler 第一句就校验参数**(缺参数抛 `errors.BadRequest`)。',
632
+ ' 保存与体检会把每个 handler 真跑一遍(空参数):不校验的 handler 会去打真网络、',
633
+ ' 真写文件,或者把块判成"体检不通过"。',
634
+ '5. **只 promise 自己做得到的事。** 前端能拿到的只有那座桥(`magicBlock.*` /',
635
+ ' `magicBackendApi.*`):用户身份、跳标签、这一块自己的后端。它读不到地址栏、',
636
+ ' localStorage 里的宿主数据、别的块的状态 —— 要这些就先说清做不到。',
637
+ '6. **键名别猜**:块配置只有带 `BLOCK_` 前缀的环境变量会进 `ctx.config`(前缀剥掉当键)。',
638
+ ' 真实键名在 `backend.lock.pyi` 的「你现在配了哪些键」那一块;提交前跑 `node smoke.lock.mjs`,',
639
+ ' 它会替你核对代码里 `ctx.config[...]` 用到的键与清单、以及开发目录旁的 `.env`。',
640
+ '7. **不许把密钥写进代码或块包**(不要 `API_KEY = "..."`):走 `ctx.config`。',
641
+ ' 块包会进数据库、也会被导出到别人机器上。',
642
+ '8. **前端可以拆文件**(入口里照常 `<script src>` / `<link href>`,包内文件会被内联),',
643
+ ' 但**入口引用的文件一个都不能少**,而且块里**没有模块系统**(不能 `import` / `export`)。',
644
+ '9. **后端也能拆文件**:包里每个 `.py` 都是**独立入口**(平台逐个加载)—— 因此',
645
+ ' **文件之间不许互相引用**(`from helper import x` 会被保存拦下)。要共用逻辑就',
646
+ ' **复制一份**,或者放进**项目里**的公共模块(那是普通 Python)。',
647
+ '10. **不许在块里 `eval` / `exec` 拼代码**,也不许改平台行为(改 `settings`、注册全局钩子)。',
648
+ '',
649
+ '## 二、"权限"这个词在这个项目里指哪一类(**四种,别混**)',
650
+ '',
651
+ '1. **某张业务表上的"权限样字段组"**(最常见、也是需求真正指的那种):',
652
+ ' 形如 `<前缀>_is_*` / `<前缀>_can_*` 的**布尔位**,备注里常带 `(仅XX)` 这类限定语。',
653
+ ' 语义是"**某个岗位上能不能干这件事**":要和**岗位 / 组织**一起读,',
654
+ ' 不是"一组可以随便下发的权限码"。索引见 `backend.lock.pyi` 每个模型上面那一行。',
655
+ '2. **平台级能力**:`ctx.user.is_staff`(管理员)、`ctx.user.groups` /',
656
+ ' `ctx.user.permissions`(Django 的 `app.codename`);后端路由可以声明',
657
+ " `@MagicBackendAPI(permission='app.codename')`。",
658
+ "3. **Django 模型级 CRUD**(`auth_permission` 里那几组 `add_/change_/delete_/view_`):",
659
+ ' **通常不是**需求说的那个"权限"。拿它当业务权限目录 = **越界**(见规矩 1)。',
660
+ '4. **外部系统**(Authing 的角色 / 分组):要另配密钥去拉,块里**默认拿不到**;',
661
+ ' 需要就先说清"要配哪几个 `BLOCK_*`",别假装它已经在库里。',
662
+ '',
663
+ '## 三、这个项目里有哪些模型(按来源分,**只读第 1 类做业务**)',
664
+ '',
665
+ '### 3.1 你自己的业务表 ← 需求里的"角色 / 权限 / 状态"只可能住在这些表的列上',
666
+ '',
667
+ ]
668
+ if by_module:
669
+ for module in sorted(by_module):
670
+ lines.append(f'- `{module}`:' + ' / '.join(by_module[module]))
671
+ else:
672
+ lines.append('- (这次导出没拿到任何业务模型:项目里还没建模型,或者模型那一层没起来)')
673
+ lines.extend([
674
+ '',
675
+ '### 3.2 平台表 ← 只管登录与块本身,**不是业务**',
676
+ '',
677
+ ('- ' + ' / '.join(f'`{t.get("module")}.{t.get("name")}`' for t in platform)) if platform
678
+ else '- (这个项目里没扫到平台表)',
679
+ ' ⚠️ 块**不该直接查它们**:要块自己或当前用户,用 `ctx.block` / `ctx.user`(那是平台的接口)。',
680
+ '',
681
+ '### 3.3 ❌ Django 自带的表 —— **不在清单里,也不许用**',
682
+ '',
683
+ '`auth_permission` / `auth_group` / `django_content_type` / `django_admin_log` /',
684
+ '`django_session` / `django_migrations` …',
685
+ '',
686
+ '它们**一张都没进 `backend.lock.pyi`**(清单里那个 `class 列表`就是全部能用模型)。',
687
+ '要"权限目录"却发现只有那几组模型级 CRUD —— 那说明**需求指的不是它**:',
688
+ '回到第 2 节,看业务表上的权限样字段组,或者组织/岗位表。',
689
+ '',
690
+ '## 四、提交前(**每次都要**)',
691
+ '',
692
+ f'1. `node {SMOKE}`(全部块)或 `node {SMOKE} <块文件夹|uuid>`(只查一个):',
693
+ ' 清单 / 前端引用链 / 后端拆文件规矩 / `ctx.config` 键名,它都会过一遍。',
694
+ '2. 回管理台「读回改动」→ 勾上块 → 保存:保存时会在子进程里**真的加载一次**,',
695
+ ' 并做引用深检(模型字段、枚举值、属性是不是还在)与 handler 试跑。',
696
+ '',
697
+ '> 拿不准就把"我打算这么做"写下来先说一遍 —— 上面十条里有八条是"想清楚就不会犯"的。',
698
+ ])
699
+ return '\n'.join(lines) + '\n'
700
+
701
+
471
702
  def dev_readme(blocks: int) -> str:
472
703
  """开发目录里的说明(铺了什么、哪几份是生成物、改完怎么回库)。
473
704
 
@@ -486,8 +717,10 @@ def dev_readme(blocks: int) -> str:
486
717
  '3. 回管理台 → 选**同一个文件夹** → 「读回改动」→ 勾上要存的块 → 保存(保存时会真的试加载一次)。\n'
487
718
  '\n'
488
719
  f'🔴 **要查什么先看 `{GUIDE}`**(一页看完:一个块长什么样、前端能调什么、后端怎么写、怎么拆文件)。\n'
489
- f'🔴 想直接抄一个现成的:`{EXAMPLE_DIR}/` 里那份是完整、能存库、能跑通的例子(身份证 OCR →\n'
490
- ' 客户档案),它**前后端都拆成了几个文件**,正好示范"拆开写"该怎么拆。\n'
720
+ f'🔴 想直接抄一个现成的:`{EXAMPLE_DIR}/` 里那份是完整、能存库、能跑通的例子'
721
+ f'(**前后端都拆成了几个文件**),正好示范"拆开写"该怎么拆。\n'
722
+ ' ⚠️ 例子里那些表名 / 列名只是它自己的场景:照抄**结构**,名字按你这个项目的来'
723
+ f'(查 `{BACKEND_LOCK}`)。\n'
491
724
  '\n'
492
725
  '## 这个目录里有什么\n'
493
726
  '\n'
@@ -43,6 +43,7 @@ PLATFORM_FILES: tuple[str, ...] = (
43
43
  'VERSION.lock',
44
44
  devkit.README,
45
45
  devkit.GUIDE,
46
+ devkit.AGENTS,
46
47
  devkit.BACKEND_LOCK,
47
48
  devkit.PAGES_LOCK,
48
49
  devkit.THEME,
@@ -117,6 +118,7 @@ _SKIP_ROOT_FILES: frozenset[str] = frozenset(
117
118
  {
118
119
  devkit.README.lower(),
119
120
  devkit.GUIDE.lower(),
121
+ devkit.AGENTS.lower(),
120
122
  devkit.BACKEND_LOCK.lower(),
121
123
  devkit.PAGES_LOCK.lower(),
122
124
  devkit.THEME.lower(),
@@ -250,6 +252,10 @@ def package_files(rows: Iterable[Any], tables: list[dict[str, Any]] | None = Non
250
252
  blocks += 1
251
253
  files[devkit.BACKEND_LOCK] = devkit.backend_lock_pyi(tables, config_keys=_config_keys())
252
254
  files[devkit.GUIDE] = devkit.dev_guide()
255
+ # 🔴 **面向编码 agent 的契约**(`AGENTS.lock.md`,根目录):那几种工具(Claude Code / Copilot …)
256
+ # 会优先读这个文件名。它讲的是"哪些绝不许做、权限住在哪"——真出过两次事故的地方
257
+ # (自造权限码装进 auth_permission;把拆文件写成块内互相 import)。
258
+ files[devkit.AGENTS] = devkit.dev_agents(tables)
253
259
  files[devkit.REQUIREMENTS] = requirements_text()
254
260
  files[devkit.README] = devkit.dev_readme(blocks)
255
261
  # 🔴 **块自检脚本**铺在开发目录顶层(`smoke.lock.mjs`):作者在**发布前**本地就能把块过一遍
@@ -266,6 +272,7 @@ def package_files(rows: Iterable[Any], tables: list[dict[str, Any]] | None = Non
266
272
  example_parts = {
267
273
  devkit.README: files[devkit.README],
268
274
  devkit.GUIDE: files[devkit.GUIDE],
275
+ devkit.AGENTS: files[devkit.AGENTS],
269
276
  devkit.BACKEND_LOCK: files[devkit.BACKEND_LOCK],
270
277
  devkit.REQUIREMENTS: files[devkit.REQUIREMENTS],
271
278
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 0.1.7
3
+ Version: 0.1.8
4
4
  Summary: Labull Framework: a pluggable Django app with Authing OIDC login and magic blocks
5
5
  Author: Labull Framework
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "labull-framework"
7
- version = "0.1.7"
7
+ version = "0.1.8"
8
8
  description = "Labull Framework: a pluggable Django app with Authing OIDC login and magic blocks"
9
9
  readme = "README-pypi.md"
10
10
  requires-python = ">=3.10"