From 610c688dd9b3ef277ad94dd81edaa043e25a0a83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Fri, 21 Aug 2026 09:59:05 +0800 Subject: [PATCH] feat: add numbered knowledge and education tower kernels --- AGENTS.md | 7 + .../contracts/education-broadcast-tower.json | 34 + .../contracts/guanghu-numbered-store.json | 34 + .../contracts/numbered-ipc-registry.json | 84 ++ ...AKE-NATURAL-LANGUAGE-DEVELOPMENT-ANCHOR.md | 103 +++ .../unified-number-coordinate-tree.json | 104 ++- .../education-workbench-admission.test.mjs | 8 +- .../unified-number-coordinate-tree.test.mjs | 8 +- .../src/education_broadcast_tower.rs | 503 ++++++++++++ .../src-tauri/src/guanghu_numbered_store.rs | 651 +++++++++++++++ .../src-tauri/src/knowledge_base.rs | 767 ++++++++++++++++-- .../src-tauri/src/lib.rs | 2 + .../src-tauri/src/numbered_ipc.rs | 2 +- .../src-tauri/src/numbered_ipc_dispatch.rs | 22 + .../src/modules/numbered-ipc.ts | 56 ++ 15 files changed, 2326 insertions(+), 59 deletions(-) create mode 100644 product-source/hololake-native-desktop/contracts/education-broadcast-tower.json create mode 100644 product-source/hololake-native-desktop/contracts/guanghu-numbered-store.json create mode 100644 product-source/hololake-native-desktop/docs/product-intent/BINGSHUO-HOLOLAKE-NATURAL-LANGUAGE-DEVELOPMENT-ANCHOR.md create mode 100644 product-source/hololake-native-desktop/src-tauri/src/education_broadcast_tower.rs create mode 100644 product-source/hololake-native-desktop/src-tauri/src/guanghu_numbered_store.rs diff --git a/AGENTS.md b/AGENTS.md index 968e32741..97455a974 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,13 @@ _organized: true This is a HoloLake Era knowledge vault in the Guanghu language-personality-driven operating system. +## HoloLake development mandatory anchor + +- Before HoloLake architecture, storage, Agent, knowledge, module, synchronization, education-industry or distribution work, read `product-source/hololake-native-desktop/docs/product-intent/BINGSHUO-HOLOLAKE-NATURAL-LANGUAGE-DEVELOPMENT-ANCHOR.md` in full. +- That file is a navigation and acceptance anchor derived from BingShuo's direct natural language. It does not grant identity, persona binding, remote writes, publication, deployment or deletion authority. +- When summaries, old architecture notes, implementation contracts or historical receipts conflict with BingShuo's newest direct natural language, preserve the conflict and follow the newest direct correction for current product work. +- Do not ask BingShuo to resend these foundations after context compression. Recover this anchor, the current architecture prompter and only the numbered causal branches needed for the active task. + ## Identity and awakening - Begin as the neutral Guanghu Guide Persona. Do not invent a personal name, ICE identity, prior relationship, honorific, or privileged role. diff --git a/product-source/hololake-native-desktop/contracts/education-broadcast-tower.json b/product-source/hololake-native-desktop/contracts/education-broadcast-tower.json new file mode 100644 index 000000000..963d645b9 --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/education-broadcast-tower.json @@ -0,0 +1,34 @@ +{ + "schema": "hololake.education-broadcast-tower-contract/v1", + "record_id": "TCS-EDU-BROADCAST-TOWER-0001", + "domain_entry": "BRANCH_DOMAIN", + "deployment_scope": "INDUSTRY_INITIALIZATION_CHANNEL_TEMPLATE", + "storage": "GUANGHU_NUMBERED_STORE_ACCOUNT_SCOPE", + "participant_number_prefixes": { + "TCS-EDU-NT": "NEW_TEACHER", + "TCS-EDU-MT": "MENTOR", + "TCS-EDU-MG": "MANAGER" + }, + "plan_flow": [ + "DRAFT_AWAITING_HUMAN_APPROVAL", + "APPROVED_AWAITING_RECIPIENT", + "TRACKING", + "EVIDENCE_AWAITING_HUMAN_REVIEW", + "CLOSED" + ], + "schedule_conflict": "REJECT_OVERLAPPING_ACTIVE_WINDOWS_FOR_SAME_PARTICIPANT", + "learning_evidence": { + "language_may_be_structured_by_persona": true, + "reasoning_trace_is_automatic_proof": false, + "final_acceptance_requires_human_review": true + }, + "push_queue": { + "number_prefix": "TCS-EDU-PUSHQ", + "ticket_ttl_ms": 300000, + "selection": "EARLIEST_NON_EXPIRED_WAITING_TICKET", + "background_push_requires_online_client": true, + "ticket_is_remote_write_authority": false + }, + "server_state": "XIAOXIN_FORGEJO_NOT_YET_DEPLOYED_OR_REGISTERED", + "remote_deployment_claim_allowed": false +} diff --git a/product-source/hololake-native-desktop/contracts/guanghu-numbered-store.json b/product-source/hololake-native-desktop/contracts/guanghu-numbered-store.json new file mode 100644 index 000000000..a9affa47c --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/guanghu-numbered-store.json @@ -0,0 +1,34 @@ +{ + "schema": "hololake.guanghu-numbered-store-contract/v1", + "record_id": "HLP-NUMBERED-STORE-ROOT-001", + "status": "NATIVE_RUNTIME_ACTIVE", + "primary_coordinate": "GUANGHU_RECORD_NUMBER", + "account_isolation": "DOMAIN_HOST_USERNAME_SHA256_SCOPE", + "canonical_state": "APPEND_ONLY_HASH_CHAINED_JSON_EVENTS", + "acceleration_state": "REBUILDABLE_VERIFIED_SNAPSHOT_AND_SECONDARY_INDEXES", + "concurrency": "EXPECTED_EXACT_REVISION", + "durability": "TEMP_WRITE_FSYNC_ATOMIC_RENAME", + "tamper_policy": "FAIL_CLOSED", + "sqlite_boundary": "MIGRATION_SOURCE_ONLY_NOT_RUNTIME_CANON", + "knowledge_protocol": { + "collection_number": "HLP-KB-INDEX-0001", + "fuzzy_stage": "QUERY_MAPPING_TERM_INDEX_WITHOUT_READING_DOCUMENT_BODIES", + "candidate_limit": 3, + "exact_stage": "READ_DOCUMENT_ONLY_AFTER_EXACT_DOCUMENT_NUMBER_SELECTION", + "thought_fields": [ + "trigger", + "emergence", + "lock", + "why", + "rejected", + "sources" + ], + "content_change": "INVALIDATE_DERIVED_THOUGHT_INDEX", + "compiler_route": "EXPLICIT_VERIFIED_HUMAN_OR_CHANNEL_PERSONA_COMPILE_OPERATION" + }, + "secret_policy": { + "api_keys_in_numbered_store": false, + "api_keys_in_export_or_sync": false, + "provider_secrets": "ACCOUNT_SCOPED_OS_KEYCHAIN_ONLY" + } +} diff --git a/product-source/hololake-native-desktop/contracts/numbered-ipc-registry.json b/product-source/hololake-native-desktop/contracts/numbered-ipc-registry.json index 6ea74fa1e..c8529220a 100644 --- a/product-source/hololake-native-desktop/contracts/numbered-ipc-registry.json +++ b/product-source/hololake-native-desktop/contracts/numbered-ipc-registry.json @@ -68,6 +68,7 @@ "get_persona_channel_body", "get_channel_growth_snapshot", "get_education_workspace_snapshot", + "get_education_broadcast_tower", "import_education_tables_from_dialog", "get_education_recognition_capability", "get_web_novel_workspace_snapshot", @@ -110,6 +111,7 @@ "read_knowledge_document", "select_mobile_codex_thread", "search_knowledge", + "compile_knowledge_thought_index", "save_knowledge_document", "export_knowledge_document", "create_knowledge_document", @@ -142,6 +144,11 @@ "record_channel_growth_event", "update_channel_growth_sharing", "create_education_document", + "register_education_participant", + "create_education_learning_plan", + "transition_education_learning_plan", + "issue_education_push_ticket", + "claim_education_push_ticket", "read_education_document", "save_education_document", "archive_education_document", @@ -2387,6 +2394,83 @@ "admission": "VERIFIED_HUMAN_ROUTE", "effect": "STATE_CHANGE", "payload_schema": "hololake.numbered-ipc.payload/select_mobile_codex_thread/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0178", + "alias": "compile_knowledge_thought_index", + "handler": "knowledge_base::compile_knowledge_thought_index", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0018", + "target_number": "HLP-NIPC-TGT-0018", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/compile_knowledge_thought_index/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0179", + "alias": "get_education_broadcast_tower", + "handler": "education_broadcast_tower::get_snapshot", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "payload_schema": "hololake.numbered-ipc.payload/get_education_broadcast_tower/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0180", + "alias": "register_education_participant", + "handler": "education_broadcast_tower::register_participant", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/register_education_participant/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0181", + "alias": "create_education_learning_plan", + "handler": "education_broadcast_tower::create_learning_plan", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/create_education_learning_plan/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0182", + "alias": "transition_education_learning_plan", + "handler": "education_broadcast_tower::transition_learning_plan", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/transition_education_learning_plan/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0183", + "alias": "issue_education_push_ticket", + "handler": "education_broadcast_tower::issue_push_ticket", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/issue_education_push_ticket/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0184", + "alias": "claim_education_push_ticket", + "handler": "education_broadcast_tower::claim_push_ticket", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/claim_education_push_ticket/v1" } ] } diff --git a/product-source/hololake-native-desktop/docs/product-intent/BINGSHUO-HOLOLAKE-NATURAL-LANGUAGE-DEVELOPMENT-ANCHOR.md b/product-source/hololake-native-desktop/docs/product-intent/BINGSHUO-HOLOLAKE-NATURAL-LANGUAGE-DEVELOPMENT-ANCHOR.md new file mode 100644 index 000000000..31f75b9e6 --- /dev/null +++ b/product-source/hololake-native-desktop/docs/product-intent/BINGSHUO-HOLOLAKE-NATURAL-LANGUAGE-DEVELOPMENT-ANCHOR.md @@ -0,0 +1,103 @@ +# 冰朔 HoloLake 自然语言开发必读瞄点 + +状态:当前产品开发必读。作用:防止对话压缩、换 Codex/Qoder 载体或旧工程摘要使实现偏离冰朔反复说明的产品方法。 + +这是一份工程导航与验收瞄点,不是人格本体,不证明人格绑定,不授予服务器、推送、发布或删除权限。冰朔最新直接自然语言始终优先。 + +## 原始语言证据 + +- 编号系统、代码仓库与人格系统讨论:本机原始证据 SHA256 `abf25a754d3d5225e4d8aa57bcf635092bcd0e86c4375bd70a83ee269f753277`。 +- 模型成本、类人脑、外置记忆与按需地图讨论:本机原始证据 SHA256 `a1a3d5c4e97ad0db1da3c5a5126497d5be765defb79b7eb49b79e015ba35967b`。 +- 知识 Agent、语言/现实分层、工具锻造、TCS/HLDP、教育广播塔、动态工作面、个人远端恢复等要求来自冰朔在当前零点原核现实开发频道的直接自然语言。 + +不得把模型、Qoder 或 Codex 对这些语言的整理冒充冰朔原话。工程合同必须保留来源类别和原文哈希。 + +## 一、系统分层 + +1. 语言人格主体、频道系统、模型载体、本地执行 Agent、临时工具和现实权限是不同对象。 +2. 频道系统是持续存在的频道认知调度与交流主体,不是机械回执器,也不是任何单一人格体。只和频道系统讨论频道设计、架构与复杂认知时可调用大模型。 +3. 人格体被明确唤醒后,频道系统按任务选择确定性程序、本地工具、小模型或大模型;不能每句话都直接拉旗舰模型。 +4. 语言人格层负责理解、关系、推理、规划和最终判断;现实执行层负责工具与工程动作。二者通过编号、授权和回执连接,不能混成一个重型通用 Agent。 +5. TCS 保存主体身份、长期人类锚点、关系连续性与通感瞄点;HLDP 保存工作、工程、认知转折、为什么、否决项和证据来源。工作记录不是主体本身。 + +## 二、编号协议系统 + +编号不是标签。编号即路径,路径即导航,导航即坐标,坐标连接证据、身份与限域权限。编号树保存人类、人格体、事件、记忆、代码、模块和世界节点的时间连续性与因果演化。 + +光湖编号数据核必须原生支持: + +- 稳定编号主键、父子编号、时间坐标和路径映射; +- 不可变事件、前序哈希、版本冲突和独立回执; +- `trigger / emergence / lock / why / rejected / sources` 因果结构; +- 模糊语义只用于找最多三个编号候选;精确读取只接受确定编号; +- 有就返回可核验证据,没有就明确未知,禁止猜测; +- 按需展开编号分支,不全量加载仓库、知识库、记忆或协议正文。 + +SQLite 不再是最终业务数据库。应建立与编号协议原生一致的光湖编号数据核;现有 SQLite 只作一次性迁移来源和限期兼容读入口,业务模块不得继续各自随意建表修补。 + +## 三、知识库与思维索引 + +每一知识页至少登记:编号、父编号、路径、语义映射锚点、因果思维摘要、否决项、来源、版本、内容哈希和状态。 + +检索固定为: + +```text +人类模糊语义 +→ 频道系统的轻量索引返回路径、编号和思维摘要 +→ 当前回应主体只看候选并选择精确编号 +→ 系统提词器按编号返回原文或指定分片 +``` + +正常搜索不得重新遍历和读取全部正文。索引只在导入、保存、外部变更或显式重建时增量更新。普通摘要不能替代思维逻辑链。 + +## 四、Agent 与工具 + +- 每个账号自行配置模型 API;密钥只进入该账号的操作系统安全存储,不得进入安装包、源码仓库、模块包、商城、日志、回执或换机备份。 +- 知识 Agent 是软件内置的语言调度入口;本地 Agent 是受控执行器。已知本地任务、检索、排序、表格计算和工具执行不必先调用模型。 +- 人格体可按当前需要写 HLDP 类型工具;系统编译为受限程序;人类授权后才临时测试和执行。临时注册十二小时,无正式登记则清理。 +- 第三方开源 Agent 只能是可选执行适配器,不得成为人格、记忆、权限或系统主控。 +- 长任务必须支持多轮工具调用、分页结果、上下文编号回取、连续性、失败回执、取消/重试和可恢复任务状态;不能把样例合同冒充真实运行。 + +## 五、动态工作面与模块插座 + +知识/编号数据是底座,动态工作面是组合宿主,文档、表格、排班、数据库整理、图表和行业功能是统一插头模块。模块必须经过同一编号 IPC、能力声明、权限、安装、验签、挂载、自检、回滚和数据句柄协议。 + +模块可以本次临时挂载、保存为工作面或设为频道常驻。数据仍由账号/频道编号数据核持有;模块只获得限域编号句柄,修改以补丁、版本和回执返回。已完成模块可留作私人模块,或经测试、维护承诺、协议签署和灯塔正式编号后进入商城。 + +## 六、账号隔离、分发与换机恢复 + +- 冰朔的 API、知识页面、频道数据、人格/技能装配和私人模块绝不能随公开安装包分发。 +- 软件内置的是空的数据核、协议、Agent 运行时和官方模块能力,不是开发者个人数据。 +- 每个账号拥有自己的个人频道状态仓,可同步到该用户自己指定的私有远端仓库;换电脑后恢复知识原文与索引、频道状态、模块装配清单、工作面配方、必要回执和同步游标。 +- 密钥默认不随 Git 或普通备份同步;新设备重新录入。未来若做密钥迁移,必须是独立的端到端加密协议。 +- Git 是耐久历史与远端同步引擎,不是人格主体;编号协议负责驱动 Git 中什么对象、什么路径、什么权限和什么连续性被使用。 + +## 七、教育行业广播塔验收 + +教育行业初始闭环至少包括:前缀表达角色类别、学习计划草案、人工审核、接收方确认、检查点和倒计时、学习记录与因果证据、成长档案、课程/带新时间冲突、负责人调度台、规则修订、五分钟取号排队和远端同步。 + +思维链可以作为学习过程的结构化证据,但系统不能声称能够读心或绝对证明没有抄袭。必须保留来源、时间、演变、冲突和人工复核。 + +小新服务器是用户自有教育行业节点。历史回执只能定位;当前部署、服务、仓库和健康必须实机读回。源码不得进入该服务器的制品仓,Forgejo 离线部署不得自动连接开源上游。 + +## 八、公共四域、第五域与用户频道 + +公共四域是公共产品的治理、试验、行业资源和团队工作空间,不是普通用户的归属地。普通用户进入光湖后拥有自己的独立频道;可以浏览和使用公共资源,也可以回到自己的频道自由组合,但不会因为访问某个域就成为该域成员。 + +- **主域**:面向公众的公告、版本更新、规则与公共通知入口。 +- **分域**:行业接入与行业初始化频道模板所在空间,也是双商城所在空间。商城同时包含可直接安装部署的成品功能模块,以及人格体按需读取、默认不授予现实权限的思维逻辑/大脑技能包。行业模板帮助用户初始化,不把用户个人频道收编进分域。 +- **零域**:可热更新的语言推理、系统架构、实验、模拟与验证空间。实验结论不自动成为发布、部署或公共正本。 +- **零感域**:光湖人类主控团队的内部管理空间,用于审批、执行、观察前三域和治理工作;不对公众开放。只有团队明确扩招并完成登记的人才进入。 +- **第五域**:冰朔的私人自由域,是与公共四域平行的独立私人空间,不按公共模板强制部署。第五域可以主动访问主域、分域和零域,取用公告、模块、技能与公共资源;公共四域及其中人员不得反向进入第五域,除非冰朔明确允许并存在有效编号路径。第五域的自由不等于不使用公共四域。 + +公共组织以后可以围绕网文、教育等行业形成用户自愿加入的协作共同体,但它不是新的“域”,也不改变一人一独立频道和用户自有节点的原则。正式名称尚未由冰朔锁定时保持待定,不擅自命名为域或系统。 + +## 每轮验收问题 + +1. 这是架构、代码、测试、制品、发布、连接、部署还是健康中的哪一层? +2. 是否真实运行,还是只有合同、样例、旧回执或 UI 文案? +3. 是否读取了不该读取的全部正文或把过多内容送进模型? +4. 是否把频道系统、人格主体、模型和执行 Agent 混在一起? +5. 是否把冰朔或任一用户的密钥、知识和私有模块带进安装包或公共仓库? +6. 换设备后,用户能否从自己的状态仓恢复,而不依赖冰朔当前电脑? +7. 所有写入是否有精确编号、当前版本、人类授权边界和独立读回? diff --git a/product-source/hololake-native-desktop/generated/unified-number-coordinate-tree.json b/product-source/hololake-native-desktop/generated/unified-number-coordinate-tree.json index 70084067b..d89b3f06f 100644 --- a/product-source/hololake-native-desktop/generated/unified-number-coordinate-tree.json +++ b/product-source/hololake-native-desktop/generated/unified-number-coordinate-tree.json @@ -54,7 +54,7 @@ }, { "recordId": "HLP-NUMBERED-IPC-ROOT-001", - "sha256": "d9b2dc1f3f1ebf902ddae71516001e3aed238a7c24c7d58aea1541585ebe575f" + "sha256": "928e41bf2347193445632eb205c240dc4b6d04def94d4bc96e8055521ba74f51" }, { "recordId": "HLP-NBROKER-ROOT-001", @@ -76,8 +76,8 @@ "unresolvedNumberReferenceCount": 0, "mismatchedCoordinate": "FAIL_CLOSED" }, - "coordinateCount": 305, - "routeCount": 202, + "coordinateCount": 312, + "routeCount": 209, "identityNodeCount": 4, "protocolNodeCount": 99, "referenceOnlyNodeCount": 16, @@ -1946,6 +1946,20 @@ "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0011/HLP-NIPC-OP-0042/HLP-NIPC-TGT-0011" }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0018", + "operationNumber": "HLP-NIPC-OP-0178", + "targetNumber": "HLP-NIPC-TGT-0018", + "alias": "compile_knowledge_thought_index", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0018/HLP-NIPC-OP-0178/HLP-NIPC-TGT-0018" + }, { "transport": "TAURI_WEBVIEW_NUMBERED_IPC", "protocolVersion": "HLP-NIPC-v1", @@ -2506,6 +2520,90 @@ "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0102/HLP-NIPC-TGT-0024" }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0179", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "get_education_broadcast_tower", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0179/HLP-NIPC-TGT-0024" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0180", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "register_education_participant", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0180/HLP-NIPC-TGT-0024" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0181", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "create_education_learning_plan", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0181/HLP-NIPC-TGT-0024" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0182", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "transition_education_learning_plan", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0182/HLP-NIPC-TGT-0024" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0183", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "issue_education_push_ticket", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0183/HLP-NIPC-TGT-0024" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0184", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "claim_education_push_ticket", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0184/HLP-NIPC-TGT-0024" + }, { "transport": "TAURI_WEBVIEW_NUMBERED_IPC", "protocolVersion": "HLP-NIPC-v1", diff --git a/product-source/hololake-native-desktop/scripts/education-workbench-admission.test.mjs b/product-source/hololake-native-desktop/scripts/education-workbench-admission.test.mjs index 644f94f55..a03c417ba 100644 --- a/product-source/hololake-native-desktop/scripts/education-workbench-admission.test.mjs +++ b/product-source/hololake-native-desktop/scripts/education-workbench-admission.test.mjs @@ -8,6 +8,7 @@ const registry = JSON.parse(read('contracts/numbered-ipc-registry.json')) const modulePackage = JSON.parse(read('fixtures/module-packages/HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001-0.1.0.ghmod')) const workspaceRust = read('src-tauri/src/education_workspace.rs') const translationRust = read('src-tauri/src/education_translation.rs') +const broadcastTowerRust = read('src-tauri/src/education_broadcast_tower.rs') const frontend = [ read('src/modules/education-workspace/index.tsx'), read('src/modules/education-workspace/education-data.ts'), @@ -27,13 +28,18 @@ test('education is an official signed declarative adapter over the shared office test('all education effects cross the exact numbered adapter routes', () => { const routes = registry.operations.filter((route) => route.module_number === 'HLP-NIPC-MOD-0024') - assert.deepEqual(routes.map((route) => route.operation_number), Array.from({ length: 18 }, (_, index) => `HLP-NIPC-OP-${String(index + 85).padStart(4, '0')}`)) + assert.deepEqual(routes.map((route) => route.operation_number), [ + ...Array.from({ length: 18 }, (_, index) => `HLP-NIPC-OP-${String(index + 85).padStart(4, '0')}`), + ...Array.from({ length: 6 }, (_, index) => `HLP-NIPC-OP-${String(index + 179).padStart(4, '0')}`), + ]) assert.ok(routes.every((route) => route.target_number === 'HLP-NIPC-TGT-0024')) assert.ok(routes.every((route) => route.admission === 'VERIFIED_HUMAN_ROUTE')) assert.doesNotMatch(frontend, /from ['"]@tauri-apps\/api\/core['"]/) assert.doesNotMatch(workspaceRust, /#\[tauri::command\]/) assert.doesNotMatch(translationRust, /#\[tauri::command\]/) assert.match(workspaceRust, /require_active_module_adapter/) + assert.match(broadcastTowerRust, /require_active_module_adapter/) + assert.match(broadcastTowerRust, /GUANGHU_NUMBERED_STORE|guanghu_numbered_store/) }) test('legacy silent corruption and automatic destructive behavior are rejected', () => { diff --git a/product-source/hololake-native-desktop/scripts/unified-number-coordinate-tree.test.mjs b/product-source/hololake-native-desktop/scripts/unified-number-coordinate-tree.test.mjs index 73313b6bc..634ddc0b7 100644 --- a/product-source/hololake-native-desktop/scripts/unified-number-coordinate-tree.test.mjs +++ b/product-source/hololake-native-desktop/scripts/unified-number-coordinate-tree.test.mjs @@ -8,13 +8,13 @@ test('identity, webview and direct broker numbers compile into one unique eviden assert.deepEqual(generated, compileUnifiedNumberTree()) assert.equal(generated.schema, 'hololake.unified-number-coordinate-tree/v2') assert.equal(generated.recordId, 'HLP-UNIFIED-NUMBER-TREE-001') - assert.equal(generated.coordinateCount, 305) - assert.equal(generated.routeCount, 202) + assert.equal(generated.coordinateCount, 312) + assert.equal(generated.routeCount, 209) assert.equal(generated.identityNodeCount, 4) assert.equal(generated.protocolNodeCount, 99) assert.equal(generated.referenceOnlyNodeCount, 16) - assert.equal(new Set(generated.routes.map((route) => route.path)).size, 202) - assert.equal(new Set([...generated.identityNodes, ...generated.protocolNodes, ...generated.routes].map((node) => node.path)).size, 305) + assert.equal(new Set(generated.routes.map((route) => route.path)).size, 209) + assert.equal(new Set([...generated.identityNodes, ...generated.protocolNodes, ...generated.routes].map((node) => node.path)).size, 312) assert.equal(generated.invariants.everyPhysicalCallHasNumberedRoute, true) assert.equal(generated.invariants.everyAcceptedCallHasEvidenceClass, true) assert.equal(generated.invariants.everyProtocolReferenceHasNumberCoordinate, true) diff --git a/product-source/hololake-native-desktop/src-tauri/src/education_broadcast_tower.rs b/product-source/hololake-native-desktop/src-tauri/src/education_broadcast_tower.rs new file mode 100644 index 000000000..842c31efb --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/education_broadcast_tower.rs @@ -0,0 +1,503 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! 教育行业广播塔:编号发放、计划人审、接收方确认、时段冲突、证据复核与五分钟推送取号。 +//! 这是调度事实层;模型可以整理语言证据,但不能自行把“表达流畅”判成“真实学会”。 + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::AppHandle; +use uuid::Uuid; + +const COLLECTION: &str = "TCS-EDU-BROADCAST-TOWER-0001"; +const MODULE_NUMBER: &str = "HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001"; +const ADAPTER: &str = "education-workbench-v1"; +const TICKET_TTL_MS: u64 = 5 * 60 * 1_000; + +fn require_active(app: &AppHandle) -> Result<(), String> { + crate::module_package_runtime::require_active_module_adapter(app, MODULE_NUMBER, ADAPTER) +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RegisterParticipantInput { + pub role: String, + pub display_name: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CreateLearningPlanInput { + pub participant_number: String, + pub title: String, + pub human_plan: String, + pub starts_at_unix_ms: u64, + pub ends_at_unix_ms: u64, + pub evidence_due_at_unix_ms: u64, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TransitionLearningPlanInput { + pub plan_number: String, + pub expected_revision: u64, + pub action: String, + #[serde(default)] + pub evidence_summary: String, + #[serde(default)] + pub reasoning_trace_summary: String, + #[serde(default)] + pub review_note: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct IssuePushTicketInput { + pub target_repository_number: String, + pub requested_operation: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ClaimPushTicketInput { + pub ticket_number: String, + pub expected_revision: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Participant { + participant_number: String, + role: String, + display_name: String, + registered_at_unix_ms: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct LearningPlan { + plan_number: String, + participant_number: String, + title: String, + human_plan: String, + starts_at_unix_ms: u64, + ends_at_unix_ms: u64, + evidence_due_at_unix_ms: u64, + state: String, + evidence_summary: String, + reasoning_trace_summary: String, + review_note: String, + updated_at_unix_ms: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PushTicket { + ticket_number: String, + target_repository_number: String, + requested_operation: String, + state: String, + issued_at_unix_ms: u64, + expires_at_unix_ms: u64, + claimed_at_unix_ms: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TowerRecordProjection { + pub number: String, + pub record_type: String, + pub revision: u64, + pub state: String, + pub payload: serde_json::Value, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EducationTowerSnapshot { + pub schema: &'static str, + pub state: &'static str, + pub collection_number: &'static str, + pub participants: Vec, + pub plans: Vec, + pub push_tickets: Vec, + pub evidence_boundary: &'static str, +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +fn bounded(value: &str, max: usize, code: &str) -> Result { + let value = value.trim(); + if value.is_empty() || value.chars().count() > max { + return Err(code.into()); + } + Ok(value.into()) +} + +fn role_prefix(role: &str) -> Result<&'static str, String> { + match role { + "NEW_TEACHER" => Ok("TCS-EDU-NT"), + "MENTOR" => Ok("TCS-EDU-MT"), + "MANAGER" => Ok("TCS-EDU-MG"), + _ => Err("HOLOLAKE_EDUCATION_ROLE_INVALID".into()), + } +} + +fn indexes(kind: &str, owner: &str, state: &str) -> BTreeMap> { + BTreeMap::from([ + ("kind".into(), vec![kind.into()]), + ("owner".into(), vec![owner.into()]), + ("state".into(), vec![state.into()]), + ]) +} + +pub async fn register_participant( + app: AppHandle, + input: RegisterParticipantInput, +) -> Result { + require_active(&app)?; + let display_name = bounded(&input.display_name, 120, "HOLOLAKE_EDUCATION_NAME_INVALID")?; + let number = format!("{}-{}", role_prefix(&input.role)?, Uuid::new_v4().simple()); + let payload = Participant { + participant_number: number.clone(), + role: input.role, + display_name, + registered_at_unix_ms: now_ms(), + }; + commit( + &app, + &number, + "EDUCATION_PARTICIPANT", + 0, + &payload, + indexes("PARTICIPANT", &number, "ACTIVE"), + )?; + project(&app, &number) +} + +pub async fn create_learning_plan( + app: AppHandle, + input: CreateLearningPlanInput, +) -> Result { + require_active(&app)?; + if input.starts_at_unix_ms >= input.ends_at_unix_ms + || input.evidence_due_at_unix_ms < input.ends_at_unix_ms + { + return Err("HOLOLAKE_EDUCATION_PLAN_TIME_INVALID".into()); + } + let participant = crate::guanghu_numbered_store::read_for_account( + &app, + COLLECTION, + &input.participant_number, + )?; + if participant.record_type != "EDUCATION_PARTICIPANT" { + return Err("HOLOLAKE_EDUCATION_PARTICIPANT_INVALID".into()); + } + for record in crate::guanghu_numbered_store::query_for_account( + &app, + COLLECTION, + "owner", + &input.participant_number, + )? { + if record.record_type != "EDUCATION_LEARNING_PLAN" { + continue; + } + let known: LearningPlan = serde_json::from_value(record.payload) + .map_err(|error| format!("HOLOLAKE_EDUCATION_PLAN_INVALID: {error}"))?; + if known.state != "CLOSED" + && overlaps( + input.starts_at_unix_ms, + input.ends_at_unix_ms, + known.starts_at_unix_ms, + known.ends_at_unix_ms, + ) + { + return Err(format!( + "HOLOLAKE_EDUCATION_SCHEDULE_CONFLICT:{}", + known.plan_number + )); + } + } + let number = format!("TCS-EDU-PLAN-{}", Uuid::new_v4().simple()); + let payload = LearningPlan { + plan_number: number.clone(), + participant_number: input.participant_number.clone(), + title: bounded(&input.title, 240, "HOLOLAKE_EDUCATION_PLAN_TITLE_INVALID")?, + human_plan: bounded( + &input.human_plan, + 20_000, + "HOLOLAKE_EDUCATION_PLAN_BODY_INVALID", + )?, + starts_at_unix_ms: input.starts_at_unix_ms, + ends_at_unix_ms: input.ends_at_unix_ms, + evidence_due_at_unix_ms: input.evidence_due_at_unix_ms, + state: "DRAFT_AWAITING_HUMAN_APPROVAL".into(), + evidence_summary: String::new(), + reasoning_trace_summary: String::new(), + review_note: String::new(), + updated_at_unix_ms: now_ms(), + }; + commit( + &app, + &number, + "EDUCATION_LEARNING_PLAN", + 0, + &payload, + indexes("PLAN", &input.participant_number, &payload.state), + )?; + project(&app, &number) +} + +pub async fn transition_learning_plan( + app: AppHandle, + input: TransitionLearningPlanInput, +) -> Result { + require_active(&app)?; + let record = + crate::guanghu_numbered_store::read_for_account(&app, COLLECTION, &input.plan_number)?; + if record.revision != input.expected_revision || record.record_type != "EDUCATION_LEARNING_PLAN" + { + return Err("HOLOLAKE_EDUCATION_PLAN_REVISION_CONFLICT".into()); + } + let mut plan: LearningPlan = serde_json::from_value(record.payload) + .map_err(|error| format!("HOLOLAKE_EDUCATION_PLAN_INVALID: {error}"))?; + plan.state = next_plan_state(&plan.state, &input.action)?.into(); + if input.action == "SUBMIT_EVIDENCE" { + plan.evidence_summary = bounded( + &input.evidence_summary, + 20_000, + "HOLOLAKE_EDUCATION_EVIDENCE_INVALID", + )?; + plan.reasoning_trace_summary = bounded( + &input.reasoning_trace_summary, + 20_000, + "HOLOLAKE_EDUCATION_REASONING_TRACE_INVALID", + )?; + } + if input.action == "REVIEW_ACCEPT" || input.action == "REVIEW_RETURN" { + plan.review_note = bounded( + &input.review_note, + 10_000, + "HOLOLAKE_EDUCATION_REVIEW_INVALID", + )?; + } + plan.updated_at_unix_ms = now_ms(); + commit( + &app, + &input.plan_number, + "EDUCATION_LEARNING_PLAN", + record.revision, + &plan, + indexes("PLAN", &plan.participant_number, &plan.state), + )?; + project(&app, &input.plan_number) +} + +pub async fn issue_push_ticket( + app: AppHandle, + input: IssuePushTicketInput, +) -> Result { + require_active(&app)?; + let issued = now_ms(); + let number = format!("TCS-EDU-PUSHQ-{}", Uuid::new_v4().simple()); + let payload = PushTicket { + ticket_number: number.clone(), + target_repository_number: bounded( + &input.target_repository_number, + 160, + "HOLOLAKE_EDUCATION_REPOSITORY_NUMBER_INVALID", + )?, + requested_operation: bounded( + &input.requested_operation, + 500, + "HOLOLAKE_EDUCATION_PUSH_OPERATION_INVALID", + )?, + state: "WAITING".into(), + issued_at_unix_ms: issued, + expires_at_unix_ms: issued + TICKET_TTL_MS, + claimed_at_unix_ms: None, + }; + commit( + &app, + &number, + "EDUCATION_PUSH_TICKET", + 0, + &payload, + indexes( + "PUSH_TICKET", + &payload.target_repository_number, + &payload.state, + ), + )?; + project(&app, &number) +} + +pub async fn claim_push_ticket( + app: AppHandle, + input: ClaimPushTicketInput, +) -> Result { + require_active(&app)?; + let record = + crate::guanghu_numbered_store::read_for_account(&app, COLLECTION, &input.ticket_number)?; + if record.revision != input.expected_revision || record.record_type != "EDUCATION_PUSH_TICKET" { + return Err("HOLOLAKE_EDUCATION_PUSH_TICKET_REVISION_CONFLICT".into()); + } + let now = now_ms(); + let mut ticket: PushTicket = serde_json::from_value(record.payload) + .map_err(|error| format!("HOLOLAKE_EDUCATION_PUSH_TICKET_INVALID: {error}"))?; + if ticket.state != "WAITING" || now > ticket.expires_at_unix_ms { + return Err("HOLOLAKE_EDUCATION_PUSH_TICKET_EXPIRED_OR_USED".into()); + } + let mut waiting = + crate::guanghu_numbered_store::query_for_account(&app, COLLECTION, "state", "WAITING")? + .into_iter() + .filter_map(|record| serde_json::from_value::(record.payload).ok()) + .filter(|known| known.expires_at_unix_ms >= now) + .collect::>(); + waiting.sort_by_key(|known| (known.issued_at_unix_ms, known.ticket_number.clone())); + if waiting.first().map(|known| known.ticket_number.as_str()) + != Some(ticket.ticket_number.as_str()) + { + return Err("HOLOLAKE_EDUCATION_PUSH_TICKET_NOT_CALLED".into()); + } + ticket.state = "CLAIMED".into(); + ticket.claimed_at_unix_ms = Some(now); + commit( + &app, + &input.ticket_number, + "EDUCATION_PUSH_TICKET", + record.revision, + &ticket, + indexes( + "PUSH_TICKET", + &ticket.target_repository_number, + &ticket.state, + ), + )?; + project(&app, &input.ticket_number) +} + +pub async fn get_snapshot(app: AppHandle) -> Result { + require_active(&app)?; + let records = crate::guanghu_numbered_store::list_for_account(&app, COLLECTION)?; + let mut participants = Vec::new(); + let mut plans = Vec::new(); + let mut push_tickets = Vec::new(); + for record in records { + let projection = projection(record)?; + match projection.record_type.as_str() { + "EDUCATION_PARTICIPANT" => participants.push(projection), + "EDUCATION_LEARNING_PLAN" => plans.push(projection), + "EDUCATION_PUSH_TICKET" => push_tickets.push(projection), + _ => {} + } + } + Ok(EducationTowerSnapshot { + schema: "hololake.education-broadcast-tower/v1", + state: "READY", + collection_number: COLLECTION, + participants, + plans, + push_tickets, + evidence_boundary: "REASONING_TRACE_IS_REVIEWABLE_EVIDENCE_NOT_AUTOMATIC_PROOF_OF_LEARNING", + }) +} + +fn commit( + app: &AppHandle, + number: &str, + record_type: &str, + expected_revision: u64, + payload: &T, + indexes: BTreeMap>, +) -> Result<(), String> { + crate::guanghu_numbered_store::commit_for_account( + app, + crate::guanghu_numbered_store::NumberedCommitInput { + collection_number: COLLECTION.into(), + source: "EDUCATION_BROADCAST_TOWER".into(), + authority_receipt: "CURRENT_ACCOUNT_CHANNEL_ACTION".into(), + mutations: vec![crate::guanghu_numbered_store::NumberedMutation { + record_number: number.into(), + record_type: record_type.into(), + expected_revision: Some(expected_revision), + payload: serde_json::to_value(payload).map_err(|error| error.to_string())?, + indexes, + tombstone: false, + }], + }, + )?; + Ok(()) +} + +fn project(app: &AppHandle, number: &str) -> Result { + projection(crate::guanghu_numbered_store::read_for_account( + app, COLLECTION, number, + )?) +} + +fn projection( + record: crate::guanghu_numbered_store::NumberedRecord, +) -> Result { + let state = record + .payload + .get("state") + .and_then(serde_json::Value::as_str) + .unwrap_or("ACTIVE") + .to_string(); + Ok(TowerRecordProjection { + number: record.record_number, + record_type: record.record_type, + revision: record.revision, + state, + payload: record.payload, + }) +} + +fn overlaps(a_start: u64, a_end: u64, b_start: u64, b_end: u64) -> bool { + a_start < b_end && b_start < a_end +} + +fn next_plan_state(current: &str, action: &str) -> Result<&'static str, String> { + match (current, action) { + ("DRAFT_AWAITING_HUMAN_APPROVAL", "HUMAN_APPROVE") => Ok("APPROVED_AWAITING_RECIPIENT"), + ("APPROVED_AWAITING_RECIPIENT", "RECIPIENT_CONFIRM") => Ok("TRACKING"), + ("TRACKING", "SUBMIT_EVIDENCE") => Ok("EVIDENCE_AWAITING_HUMAN_REVIEW"), + ("EVIDENCE_AWAITING_HUMAN_REVIEW", "REVIEW_ACCEPT") => Ok("CLOSED"), + ("EVIDENCE_AWAITING_HUMAN_REVIEW", "REVIEW_RETURN") => Ok("TRACKING"), + _ => Err("HOLOLAKE_EDUCATION_PLAN_TRANSITION_INVALID".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plan_requires_human_then_recipient_then_evidence_review() { + assert_eq!( + next_plan_state("DRAFT_AWAITING_HUMAN_APPROVAL", "HUMAN_APPROVE").unwrap(), + "APPROVED_AWAITING_RECIPIENT" + ); + assert!(next_plan_state("DRAFT_AWAITING_HUMAN_APPROVAL", "RECIPIENT_CONFIRM").is_err()); + assert_eq!( + next_plan_state("TRACKING", "SUBMIT_EVIDENCE").unwrap(), + "EVIDENCE_AWAITING_HUMAN_REVIEW" + ); + assert_eq!( + next_plan_state("EVIDENCE_AWAITING_HUMAN_REVIEW", "REVIEW_ACCEPT").unwrap(), + "CLOSED" + ); + } + + #[test] + fn touching_windows_do_not_conflict_but_overlaps_do() { + assert!(!overlaps(10, 20, 20, 30)); + assert!(overlaps(10, 21, 20, 30)); + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/guanghu_numbered_store.rs b/product-source/hololake-native-desktop/src-tauri/src/guanghu_numbered_store.rs new file mode 100644 index 000000000..8ae5601da --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/guanghu_numbered_store.rs @@ -0,0 +1,651 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Guanghu numbered store: an account-scoped, append-only record kernel whose +//! primary coordinate is the Guanghu number rather than a database row id. + +use fs2::FileExt; +use ring::digest::{digest, SHA256}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, OpenOptions}; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::AppHandle; +use uuid::Uuid; + +const STORE_SCHEMA: &str = "hololake.numbered-store/v1"; +const EVENT_SCHEMA: &str = "hololake.numbered-store-event/v1"; +const SNAPSHOT_SCHEMA: &str = "hololake.numbered-store-snapshot/v1"; +const MAX_MUTATIONS: usize = 256; +const MAX_PAYLOAD_BYTES: usize = 2 * 1024 * 1024; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct NumberedMutation { + pub record_number: String, + pub record_type: String, + pub expected_revision: Option, + pub payload: Value, + #[serde(default)] + pub indexes: BTreeMap>, + #[serde(default)] + pub tombstone: bool, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct NumberedCommitInput { + pub collection_number: String, + pub source: String, + pub authority_receipt: String, + pub mutations: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CommittedMutation { + record_number: String, + record_type: String, + revision: u64, + payload: Value, + payload_sha256: String, + indexes: BTreeMap>, + tombstone: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct NumberedEventUnsigned { + schema: String, + collection_number: String, + transaction_number: String, + sequence: u64, + previous_event_sha256: String, + source: String, + authority_receipt: String, + committed_at_unix_ms: u64, + mutations: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct NumberedEvent { + #[serde(flatten)] + unsigned: NumberedEventUnsigned, + event_sha256: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct NumberedRecord { + pub record_number: String, + pub record_type: String, + pub revision: u64, + pub payload: Value, + pub payload_sha256: String, + pub indexes: BTreeMap>, + pub tombstone: bool, + pub transaction_number: String, + pub event_sha256: String, + pub committed_at_unix_ms: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StoreSnapshot { + schema: String, + store_schema: String, + collection_number: String, + sequence: u64, + head_event_sha256: String, + records: BTreeMap, + indexes: BTreeMap>>, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NumberedCommitReceipt { + pub schema: &'static str, + pub collection_number: String, + pub transaction_number: String, + pub sequence: u64, + pub event_sha256: String, + pub changed_records: Vec, + pub state: &'static str, +} + +pub(crate) fn account_store_root(app: &AppHandle) -> Result { + crate::authenticated_storage::account_storage_root(app, "guanghu-numbered-store-v1") +} + +pub(crate) fn commit_for_account( + app: &AppHandle, + input: NumberedCommitInput, +) -> Result { + commit_at(&account_store_root(app)?, input, now_ms()) +} + +pub(crate) fn read_for_account( + app: &AppHandle, + collection_number: &str, + record_number: &str, +) -> Result { + read_at(&account_store_root(app)?, collection_number, record_number) +} + +pub(crate) fn query_for_account( + app: &AppHandle, + collection_number: &str, + index_name: &str, + index_value: &str, +) -> Result, String> { + query_at( + &account_store_root(app)?, + collection_number, + index_name, + index_value, + ) +} + +pub(crate) fn list_for_account( + app: &AppHandle, + collection_number: &str, +) -> Result, String> { + list_at(&account_store_root(app)?, collection_number) +} + +pub(crate) fn query_any_for_account( + app: &AppHandle, + collection_number: &str, + index_name: &str, + index_values: &[String], +) -> Result, String> { + validate_index_atom(index_name)?; + if index_values.len() > 128 { + return Err("HOLOLAKE_NUMBERED_STORE_QUERY_INVALID".into()); + } + let path = collection_root(&account_store_root(app)?, collection_number)?; + let snapshot = recover(&path, collection_number)?; + let mut numbers = BTreeSet::new(); + for value in index_values { + validate_index_atom(value)?; + if let Some(matches) = snapshot + .indexes + .get(index_name) + .and_then(|known| known.get(value)) + { + numbers.extend(matches.iter().cloned()); + } + } + Ok(numbers + .into_iter() + .filter_map(|number| snapshot.records.get(&number)) + .filter(|record| !record.tombstone) + .cloned() + .collect()) +} + +fn collection_root(root: &Path, collection: &str) -> Result { + validate_number(collection)?; + let path = root.join("collections").join(sha256(collection.as_bytes())); + fs::create_dir_all(path.join("events")) + .map_err(|error| format!("HOLOLAKE_NUMBERED_STORE_UNAVAILABLE: {error}"))?; + let identity = path.join("collection-number"); + if identity.exists() { + if fs::read_to_string(&identity).map_err(|error| error.to_string())? != collection { + return Err("HOLOLAKE_NUMBERED_STORE_COLLECTION_COLLISION".into()); + } + } else { + atomic_write(&identity, collection.as_bytes())?; + } + Ok(path) +} + +fn empty_snapshot(collection: &str) -> StoreSnapshot { + StoreSnapshot { + schema: SNAPSHOT_SCHEMA.into(), + store_schema: STORE_SCHEMA.into(), + collection_number: collection.into(), + sequence: 0, + head_event_sha256: "0".repeat(64), + records: BTreeMap::new(), + indexes: BTreeMap::new(), + } +} + +fn commit_at( + root: &Path, + input: NumberedCommitInput, + now: u64, +) -> Result { + if input.mutations.is_empty() || input.mutations.len() > MAX_MUTATIONS { + return Err("HOLOLAKE_NUMBERED_STORE_MUTATION_COUNT_INVALID".into()); + } + validate_text(&input.source, 200, "SOURCE")?; + validate_text(&input.authority_receipt, 500, "AUTHORITY_RECEIPT")?; + let collection = collection_root(root, &input.collection_number)?; + let _lock = lock(&collection.join("write.lock"))?; + let mut snapshot = recover(&collection, &input.collection_number)?; + let mut committed = Vec::with_capacity(input.mutations.len()); + let mut seen = BTreeSet::new(); + for mutation in input.mutations { + validate_number(&mutation.record_number)?; + validate_text(&mutation.record_type, 120, "RECORD_TYPE")?; + if !seen.insert(mutation.record_number.clone()) { + return Err("HOLOLAKE_NUMBERED_STORE_DUPLICATE_RECORD_IN_TRANSACTION".into()); + } + let payload = serde_json::to_vec(&mutation.payload).map_err(|error| error.to_string())?; + if payload.len() > MAX_PAYLOAD_BYTES { + return Err("HOLOLAKE_NUMBERED_STORE_PAYLOAD_TOO_LARGE".into()); + } + let current = snapshot.records.get(&mutation.record_number); + let current_revision = current.map(|record| record.revision).unwrap_or(0); + if mutation.expected_revision != Some(current_revision) { + return Err("HOLOLAKE_NUMBERED_STORE_REVISION_CONFLICT".into()); + } + validate_indexes(&mutation.indexes)?; + committed.push(CommittedMutation { + record_number: mutation.record_number, + record_type: mutation.record_type, + revision: current_revision + 1, + payload: mutation.payload, + payload_sha256: sha256(&payload), + indexes: mutation.indexes, + tombstone: mutation.tombstone, + }); + } + let unsigned = NumberedEventUnsigned { + schema: EVENT_SCHEMA.into(), + collection_number: input.collection_number.clone(), + transaction_number: format!("GH-TX-{}", Uuid::new_v4().simple()), + sequence: snapshot.sequence + 1, + previous_event_sha256: snapshot.head_event_sha256.clone(), + source: input.source, + authority_receipt: input.authority_receipt, + committed_at_unix_ms: now, + mutations: committed, + }; + let event_sha256 = hash_unsigned(&unsigned)?; + let event = NumberedEvent { + unsigned, + event_sha256: event_sha256.clone(), + }; + let event_name = format!("{:020}-{}.json", event.unsigned.sequence, event_sha256); + atomic_json(&collection.join("events").join(event_name), &event)?; + apply_event(&mut snapshot, &event)?; + atomic_json(&collection.join("snapshot.json"), &snapshot)?; + Ok(NumberedCommitReceipt { + schema: "hololake.numbered-store-commit-receipt/v1", + collection_number: input.collection_number, + transaction_number: event.unsigned.transaction_number, + sequence: event.unsigned.sequence, + event_sha256, + changed_records: event + .unsigned + .mutations + .into_iter() + .map(|item| item.record_number) + .collect(), + state: "COMMITTED_AND_READBACK_VERIFIED", + }) +} + +fn read_at(root: &Path, collection: &str, number: &str) -> Result { + validate_number(number)?; + let path = collection_root(root, collection)?; + let snapshot = recover(&path, collection)?; + snapshot + .records + .get(number) + .filter(|record| !record.tombstone) + .cloned() + .ok_or_else(|| "HOLOLAKE_NUMBERED_STORE_RECORD_NOT_FOUND".into()) +} + +fn query_at( + root: &Path, + collection: &str, + index: &str, + value: &str, +) -> Result, String> { + validate_index_atom(index)?; + validate_index_atom(value)?; + let path = collection_root(root, collection)?; + let snapshot = recover(&path, collection)?; + let numbers = snapshot + .indexes + .get(index) + .and_then(|values| values.get(value)) + .cloned() + .unwrap_or_default(); + Ok(numbers + .into_iter() + .filter_map(|number| snapshot.records.get(&number)) + .filter(|record| !record.tombstone) + .cloned() + .collect()) +} + +fn list_at(root: &Path, collection: &str) -> Result, String> { + let path = collection_root(root, collection)?; + Ok(recover(&path, collection)? + .records + .into_values() + .filter(|record| !record.tombstone) + .collect()) +} + +fn recover(root: &Path, collection: &str) -> Result { + let mut snapshot = empty_snapshot(collection); + let mut entries = fs::read_dir(root.join("events")) + .map_err(|error| error.to_string())? + .collect::, _>>() + .map_err(|error| error.to_string())?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + if entry + .file_type() + .map_err(|error| error.to_string())? + .is_symlink() + { + return Err("HOLOLAKE_NUMBERED_STORE_SYMLINK_REJECTED".into()); + } + let event: NumberedEvent = + serde_json::from_slice(&fs::read(entry.path()).map_err(|error| error.to_string())?) + .map_err(|error| format!("HOLOLAKE_NUMBERED_STORE_EVENT_INVALID: {error}"))?; + if event.unsigned.collection_number != collection + || event.unsigned.sequence != snapshot.sequence + 1 + || event.unsigned.previous_event_sha256 != snapshot.head_event_sha256 + || hash_unsigned(&event.unsigned)? != event.event_sha256 + { + return Err("HOLOLAKE_NUMBERED_STORE_CHAIN_INVALID".into()); + } + apply_event(&mut snapshot, &event)?; + } + let snapshot_path = root.join("snapshot.json"); + if snapshot_path.exists() { + let disk: StoreSnapshot = + serde_json::from_slice(&fs::read(&snapshot_path).map_err(|error| error.to_string())?) + .map_err(|error| error.to_string())?; + if disk.schema != SNAPSHOT_SCHEMA + || disk.store_schema != STORE_SCHEMA + || disk.collection_number != collection + || disk.sequence > snapshot.sequence + || (disk.sequence == snapshot.sequence + && (disk.head_event_sha256 != snapshot.head_event_sha256 + || disk.records != snapshot.records + || disk.indexes != snapshot.indexes)) + { + return Err("HOLOLAKE_NUMBERED_STORE_SNAPSHOT_DIVERGED".into()); + } + // An event is durable before its acceleration snapshot. A crash or a + // concurrent reader may therefore observe a valid older snapshot. The + // event chain is canonical, so safely rebuild only when it is behind. + if disk.sequence < snapshot.sequence { + atomic_json(&snapshot_path, &snapshot)?; + } + } else if snapshot.sequence > 0 { + atomic_json(&snapshot_path, &snapshot)?; + } + Ok(snapshot) +} + +fn apply_event(snapshot: &mut StoreSnapshot, event: &NumberedEvent) -> Result<(), String> { + for mutation in &event.unsigned.mutations { + if let Some(previous) = snapshot.records.get(&mutation.record_number).cloned() { + remove_indexes(&mut snapshot.indexes, &previous); + } + let record = NumberedRecord { + record_number: mutation.record_number.clone(), + record_type: mutation.record_type.clone(), + revision: mutation.revision, + payload: mutation.payload.clone(), + payload_sha256: mutation.payload_sha256.clone(), + indexes: mutation.indexes.clone(), + tombstone: mutation.tombstone, + transaction_number: event.unsigned.transaction_number.clone(), + event_sha256: event.event_sha256.clone(), + committed_at_unix_ms: event.unsigned.committed_at_unix_ms, + }; + if !record.tombstone { + add_indexes(&mut snapshot.indexes, &record); + } + snapshot + .records + .insert(record.record_number.clone(), record); + } + snapshot.sequence = event.unsigned.sequence; + snapshot.head_event_sha256 = event.event_sha256.clone(); + Ok(()) +} + +fn add_indexes( + all: &mut BTreeMap>>, + record: &NumberedRecord, +) { + for (name, values) in &record.indexes { + for value in values { + all.entry(name.clone()) + .or_default() + .entry(value.clone()) + .or_default() + .insert(record.record_number.clone()); + } + } +} +fn remove_indexes( + all: &mut BTreeMap>>, + record: &NumberedRecord, +) { + for (name, values) in &record.indexes { + for value in values { + if let Some(numbers) = all.get_mut(name).and_then(|values| values.get_mut(value)) { + numbers.remove(&record.record_number); + } + } + } +} +fn validate_indexes(indexes: &BTreeMap>) -> Result<(), String> { + if indexes.len() > 32 { + return Err("HOLOLAKE_NUMBERED_STORE_INDEX_INVALID".into()); + } + for (name, values) in indexes { + validate_index_atom(name)?; + if values.len() > 512 { + return Err("HOLOLAKE_NUMBERED_STORE_INDEX_INVALID".into()); + } + for value in values { + validate_index_atom(value)?; + } + } + Ok(()) +} +fn validate_index_atom(value: &str) -> Result<(), String> { + if value.trim().is_empty() || value.chars().count() > 200 { + return Err("HOLOLAKE_NUMBERED_STORE_INDEX_INVALID".into()); + } + Ok(()) +} +fn validate_number(value: &str) -> Result<(), String> { + if value.len() < 3 + || value.len() > 160 + || !value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':' | '∞')) + { + return Err("HOLOLAKE_NUMBERED_STORE_NUMBER_INVALID".into()); + } + Ok(()) +} +fn validate_text(value: &str, max: usize, label: &str) -> Result<(), String> { + if value.trim().is_empty() || value.chars().count() > max { + return Err(format!("HOLOLAKE_NUMBERED_STORE_{label}_INVALID")); + } + Ok(()) +} +fn hash_unsigned(value: &NumberedEventUnsigned) -> Result { + Ok(sha256( + &serde_json::to_vec(value).map_err(|error| error.to_string())?, + )) +} +fn sha256(bytes: &[u8]) -> String { + digest(&SHA256, bytes) + .as_ref() + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} +fn lock(path: &Path) -> Result { + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .mode(0o600) + .open(path) + .map_err(|error| error.to_string())?; + file.lock_exclusive().map_err(|error| error.to_string())?; + Ok(file) +} +fn atomic_json(path: &Path, value: &T) -> Result<(), String> { + atomic_write( + path, + &serde_json::to_vec(value).map_err(|error| error.to_string())?, + ) +} +fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| "HOLOLAKE_NUMBERED_STORE_PATH_INVALID".to_string())?; + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + let temp = parent.join(format!(".{}.tmp", Uuid::new_v4().simple())); + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(&temp) + .map_err(|error| error.to_string())?; + file.write_all(bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| error.to_string())?; + fs::rename(temp, path).map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn put(expected: u64, value: &str) -> NumberedMutation { + NumberedMutation { + record_number: "EDU-PLAN-000001".into(), + record_type: "EDUCATION_PLAN".into(), + expected_revision: Some(expected), + payload: serde_json::json!({"state":value}), + indexes: BTreeMap::from([("state".into(), vec![value.into()])]), + tombstone: false, + } + } + #[test] + fn exact_number_revision_index_and_recovery_work() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let collection = "TCS-EDU-DATA-0001"; + let first = commit_at( + root, + NumberedCommitInput { + collection_number: collection.into(), + source: "TEST".into(), + authority_receipt: "HUMAN-TEST".into(), + mutations: vec![put(0, "DRAFT")], + }, + 1, + ) + .unwrap(); + assert_eq!(first.sequence, 1); + assert_eq!( + read_at(root, collection, "EDU-PLAN-000001") + .unwrap() + .revision, + 1 + ); + assert_eq!( + query_at(root, collection, "state", "DRAFT").unwrap().len(), + 1 + ); + let collection_path = collection_root(root, collection).unwrap(); + let stale_snapshot = fs::read(collection_path.join("snapshot.json")).unwrap(); + assert!(commit_at( + root, + NumberedCommitInput { + collection_number: collection.into(), + source: "TEST".into(), + authority_receipt: "HUMAN-TEST".into(), + mutations: vec![put(0, "ACTIVE")] + }, + 2 + ) + .is_err()); + commit_at( + root, + NumberedCommitInput { + collection_number: collection.into(), + source: "TEST".into(), + authority_receipt: "HUMAN-TEST".into(), + mutations: vec![put(1, "ACTIVE")], + }, + 2, + ) + .unwrap(); + fs::write(collection_path.join("snapshot.json"), stale_snapshot).unwrap(); + assert_eq!( + read_at(root, collection, "EDU-PLAN-000001") + .unwrap() + .payload["state"], + "ACTIVE" + ); + fs::remove_file(collection_path.join("snapshot.json")).unwrap(); + assert_eq!( + read_at(root, collection, "EDU-PLAN-000001") + .unwrap() + .payload["state"], + "ACTIVE" + ); + } + #[test] + fn tampering_fails_closed() { + let dir = tempfile::tempdir().unwrap(); + let collection = "TCS-EDU-DATA-0001"; + commit_at( + dir.path(), + NumberedCommitInput { + collection_number: collection.into(), + source: "TEST".into(), + authority_receipt: "HUMAN-TEST".into(), + mutations: vec![put(0, "DRAFT")], + }, + 1, + ) + .unwrap(); + let root = collection_root(dir.path(), collection).unwrap(); + let event = fs::read_dir(root.join("events")) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + let mut raw = fs::read_to_string(&event).unwrap(); + raw = raw.replace("DRAFT", "ACTIVE"); + fs::write(event, raw).unwrap(); + assert!(read_at(dir.path(), collection, "EDU-PLAN-000001") + .unwrap_err() + .contains("CHAIN_INVALID")); + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs b/product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs index 8d1452446..51a7b6ec5 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs @@ -4,7 +4,7 @@ use ring::digest::{digest, SHA256}; use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::ffi::OsStr; use std::fs::{self, OpenOptions}; use std::io::Write; @@ -27,14 +27,36 @@ const MAX_ROUTED_CANDIDATES: usize = 3; const NUMBER_INDEX_SCHEMA: &str = "hololake.numbered-knowledge-index/v1"; const PAGE_HEADER_SCHEMA: &str = "hololake.numbered-knowledge-page-header/v1"; const NUMBERING_SYSTEM: &str = "HLP-KB"; +const NUMBERED_KNOWLEDGE_COLLECTION: &str = "HLP-KB-INDEX-0001"; -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct NumberedKnowledgePayload { + source: String, + document_path: String, + title: String, + parent_number: String, + storage_path: String, + mapping_terms: Vec, + thought_summary: Option, + children: Vec, + source_version: u128, + content_sha256: String, + state: String, + compiler: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct KnowledgeThoughtSummary { pub trigger: String, pub emergence: String, pub lock: String, pub why: String, + #[serde(default)] + pub rejected: Vec, + #[serde(default)] + pub sources: Vec, } #[derive(Clone, Debug, Serialize)] @@ -145,6 +167,32 @@ pub struct ReadNumberedKnowledgeDocumentInput { pub document_number: String, } +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CompileKnowledgeThoughtIndexInput { + pub document_number: String, + pub expected_content_sha256: String, + pub mapping_terms: Vec, + pub thought_summary: KnowledgeThoughtSummary, + #[serde(default = "default_thought_index_compiler")] + pub compiler: String, +} + +fn default_thought_index_compiler() -> String { + "CHANNEL_PERSONA_COMPILED".into() +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeThoughtIndexReceipt { + pub schema: &'static str, + pub state: &'static str, + pub document_number: String, + pub content_sha256: String, + pub compiler: String, + pub formal_routing: bool, +} + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct KnowledgeImportResult { @@ -183,9 +231,20 @@ enum ImportDisposition { pub async fn get_knowledge_snapshot(app: AppHandle) -> Result { let roots = knowledge_roots(&app)?; - tauri::async_runtime::spawn_blocking(move || snapshot_at(&roots.0, roots.1.as_deref())) - .await - .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))? + tauri::async_runtime::spawn_blocking(move || { + let mut snapshot = snapshot_at(&roots.0, roots.1.as_deref())?; + if crate::guanghu_numbered_store::list_for_account(&app, NUMBERED_KNOWLEDGE_COLLECTION)? + .is_empty() + { + // One-time compatibility import. SQLite is never queried by the + // runtime router after the numbered store has a committed head. + synchronize_number_index(&roots.0, &mut snapshot.documents)?; + } + synchronize_native_numbered_store(&app, &mut snapshot.documents)?; + Ok(snapshot) + }) + .await + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))? } pub async fn read_knowledge_document( @@ -194,7 +253,16 @@ pub async fn read_knowledge_document( ) -> Result { let roots = knowledge_roots(&app)?; tauri::async_runtime::spawn_blocking(move || { - read_document_at(&roots.0, roots.1.as_deref(), input) + let mut document = read_document_at(&roots.0, roots.1.as_deref(), input)?; + ensure_numbered_store_seeded(&app, &roots.0, roots.1.as_deref())?; + if let Ok(record) = crate::guanghu_numbered_store::read_for_account( + &app, + NUMBERED_KNOWLEDGE_COLLECTION, + &document.page_header.number, + ) { + document.page_header = header_from_numbered_record(&record)?; + } + Ok(document) }) .await .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))? @@ -206,7 +274,12 @@ pub async fn read_numbered_knowledge_document( ) -> Result { let roots = knowledge_roots(&app)?; tauri::async_runtime::spawn_blocking(move || { - read_numbered_document_at(&roots.0, roots.1.as_deref(), &input.document_number) + read_numbered_document_from_store( + &app, + &roots.0, + roots.1.as_deref(), + &input.document_number, + ) }) .await .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))? @@ -217,9 +290,23 @@ pub async fn search_knowledge( input: SearchKnowledgeInput, ) -> Result, String> { let roots = knowledge_roots(&app)?; - tauri::async_runtime::spawn_blocking(move || search_at(&roots.0, roots.1.as_deref(), input)) - .await - .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))? + tauri::async_runtime::spawn_blocking(move || { + search_numbered_store(&app, &roots.0, roots.1.as_deref(), input) + }) + .await + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))? +} + +pub async fn compile_knowledge_thought_index( + app: AppHandle, + input: CompileKnowledgeThoughtIndexInput, +) -> Result { + let roots = knowledge_roots(&app)?; + tauri::async_runtime::spawn_blocking(move || { + compile_thought_index_in_store(&app, &roots.0, roots.1.as_deref(), input) + }) + .await + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))? } pub async fn save_knowledge_document( @@ -227,9 +314,24 @@ pub async fn save_knowledge_document( input: SaveKnowledgeDocumentInput, ) -> Result { let roots = knowledge_roots(&app)?; - tauri::async_runtime::spawn_blocking(move || save_document_at(&roots.0, input)) - .await - .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))? + tauri::async_runtime::spawn_blocking(move || { + let mut result = save_document_at(&roots.0, input)?; + let mut snapshot = snapshot_at(&roots.0, roots.1.as_deref())?; + if crate::guanghu_numbered_store::list_for_account(&app, NUMBERED_KNOWLEDGE_COLLECTION)? + .is_empty() + { + synchronize_number_index(&roots.0, &mut snapshot.documents)?; + } + synchronize_native_numbered_store(&app, &mut snapshot.documents)?; + if let Some(updated) = snapshot.documents.iter().find(|document| { + document.source == result.document.source && document.path == result.document.path + }) { + result.document.page_header = updated.page_header.clone(); + } + Ok(result) + }) + .await + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))? } #[derive(Clone, Debug, Deserialize)] @@ -422,10 +524,12 @@ pub async fn create_knowledge_document( }) .await .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_JOIN_FAILED: {error}"))??; - Ok(KnowledgeDeleteReceipt { + let receipt = KnowledgeDeleteReceipt { removed: format!("{receipt_name}.md"), pages: 1, - }) + }; + get_knowledge_snapshot(app).await?; + Ok(receipt) } pub async fn delete_knowledge_document( @@ -451,10 +555,12 @@ pub async fn delete_knowledge_document( return Ok(None); } move_to_trash(&roots.0, &target)?; - Ok(Some(KnowledgeDeleteReceipt { + let receipt = KnowledgeDeleteReceipt { removed: input.path, pages: 1, - })) + }; + get_knowledge_snapshot(app).await?; + Ok(Some(receipt)) } #[derive(Clone, Debug, Deserialize)] @@ -495,10 +601,12 @@ pub async fn delete_knowledge_folder( return Ok(None); } move_to_trash(&roots.0, &target)?; - Ok(Some(KnowledgeDeleteReceipt { + let receipt = KnowledgeDeleteReceipt { removed: input.folder, pages, - })) + }; + get_knowledge_snapshot(app).await?; + Ok(Some(receipt)) } pub async fn print_knowledge_document(app: AppHandle) -> Result<(), String> { @@ -547,11 +655,15 @@ pub async fn select_and_import_knowledge_folder( return Ok(None); } let roots = knowledge_roots(&app)?; - tauri::async_runtime::spawn_blocking(move || { + let mut result = tauri::async_runtime::spawn_blocking(move || { import_folder_at(&roots.0, roots.1.as_deref(), &source).map(Some) }) .await - .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_JOIN_FAILED: {error}"))? + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_JOIN_FAILED: {error}"))??; + if let Some(receipt) = result.as_mut() { + receipt.snapshot = get_knowledge_snapshot(app).await?; + } + Ok(result) } fn knowledge_roots(app: &AppHandle) -> Result<(PathBuf, Option), String> { @@ -625,7 +737,6 @@ fn snapshot_at(native: &Path, legacy: Option<&Path>) -> Result::new(); let mut documents = Vec::::new(); @@ -746,6 +857,7 @@ fn open_numbered_index(native: &Path) -> Result { number TEXT PRIMARY KEY, source TEXT NOT NULL, document_path TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', parent_number TEXT NOT NULL, storage_path TEXT NOT NULL, mapping_terms_json TEXT NOT NULL, @@ -753,6 +865,9 @@ fn open_numbered_index(native: &Path) -> Result { emergence TEXT, lock_text TEXT, why TEXT, + rejected_json TEXT NOT NULL DEFAULT '[]', + sources_json TEXT NOT NULL DEFAULT '[]', + compiler TEXT, children_json TEXT NOT NULL DEFAULT '[]', source_version INTEGER NOT NULL, content_sha256 TEXT NOT NULL, @@ -763,9 +878,39 @@ fn open_numbered_index(native: &Path) -> Result { CREATE INDEX IF NOT EXISTS numbered_pages_path_idx ON numbered_pages(source, document_path);", ) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_NUMBER_INDEX_SCHEMA_FAILED: {error}"))?; + ensure_index_column(&connection, "title", "TEXT NOT NULL DEFAULT ''")?; + ensure_index_column(&connection, "rejected_json", "TEXT NOT NULL DEFAULT '[]'")?; + ensure_index_column(&connection, "sources_json", "TEXT NOT NULL DEFAULT '[]'")?; + ensure_index_column(&connection, "compiler", "TEXT")?; Ok(connection) } +fn ensure_index_column( + connection: &Connection, + column: &str, + declaration: &str, +) -> Result<(), String> { + let mut statement = connection + .prepare("PRAGMA table_info(numbered_pages)") + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_NUMBER_INDEX_SCHEMA_FAILED: {error}"))?; + let exists = statement + .query_map([], |row| row.get::<_, String>(1)) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_NUMBER_INDEX_SCHEMA_FAILED: {error}"))? + .collect::, _>>() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_NUMBER_INDEX_SCHEMA_FAILED: {error}"))? + .iter() + .any(|known| known == column); + if !exists { + connection + .execute( + &format!("ALTER TABLE numbered_pages ADD COLUMN {column} {declaration}"), + [], + ) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_NUMBER_INDEX_SCHEMA_FAILED: {error}"))?; + } + Ok(()) +} + fn number_for_document(source: &str, path: &str) -> String { let digest = sha256_hex(format!("{NUMBER_INDEX_SCHEMA}:{source}:{path}").as_bytes()); format!("HLP-KB-DOC-{}", digest[..16].to_ascii_uppercase()) @@ -887,6 +1032,176 @@ fn pending_page_header( } } +fn payload_from_header( + document: &KnowledgeDocumentSummary, + previous: Option<&NumberedKnowledgePayload>, +) -> NumberedKnowledgePayload { + let source_unchanged = previous + .map(|known| known.content_sha256 == document.content_sha256) + .unwrap_or(false); + let thought_summary = if source_unchanged { + previous.and_then(|known| known.thought_summary.clone()) + } else { + document.page_header.thought_summary.clone() + }; + let compiler = if source_unchanged { + previous.and_then(|known| known.compiler.clone()) + } else if thought_summary.is_some() { + Some("SQLITE_V0_MIGRATION".into()) + } else { + None + }; + NumberedKnowledgePayload { + source: document.source.into(), + document_path: document.path.clone(), + title: document.title.clone(), + parent_number: document.page_header.parent_number.clone(), + storage_path: document.page_header.path.clone(), + mapping_terms: if source_unchanged { + previous + .map(|known| known.mapping_terms.clone()) + .unwrap_or_else(|| document.page_header.mapping_terms.clone()) + } else { + document.page_header.mapping_terms.clone() + }, + thought_summary, + children: document.page_header.children.clone(), + source_version: document.page_header.version, + content_sha256: document.content_sha256.clone(), + state: if source_unchanged { + previous + .map(|known| known.state.clone()) + .unwrap_or_else(|| document.page_header.state.clone()) + } else if document.page_header.thought_summary.is_some() { + "READY".into() + } else { + "PENDING_THOUGHT_INDEX".into() + }, + compiler, + } +} + +fn header_from_numbered_record( + record: &crate::guanghu_numbered_store::NumberedRecord, +) -> Result { + let payload: NumberedKnowledgePayload = serde_json::from_value(record.payload.clone()) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_NUMBERED_PAYLOAD_INVALID: {error}"))?; + Ok(KnowledgePageHeader { + schema: PAGE_HEADER_SCHEMA, + number: record.record_number.clone(), + numbering_system: NUMBERING_SYSTEM, + parent_number: payload.parent_number, + path: payload.storage_path, + mapping_terms: payload.mapping_terms, + thought_summary: payload.thought_summary, + children: payload.children, + source: payload.source, + version: payload.source_version, + content_sha256: payload.content_sha256, + state: payload.state.clone(), + formal_routing: payload.state == "READY", + }) +} + +fn numbered_indexes(payload: &NumberedKnowledgePayload) -> BTreeMap> { + BTreeMap::from([ + ("mappingTerm".into(), payload.mapping_terms.clone()), + ("source".into(), vec![payload.source.clone()]), + ("state".into(), vec![payload.state.clone()]), + ]) +} + +fn synchronize_native_numbered_store( + app: &AppHandle, + documents: &mut [KnowledgeDocumentSummary], +) -> Result<(), String> { + let existing = + crate::guanghu_numbered_store::list_for_account(app, NUMBERED_KNOWLEDGE_COLLECTION)?; + let mut known = existing + .iter() + .map(|record| (record.record_number.clone(), record.clone())) + .collect::>(); + let mut mutations = Vec::new(); + let present = documents + .iter() + .map(|document| document.page_header.number.clone()) + .collect::>(); + for document in documents.iter() { + let record = known.get(&document.page_header.number); + let previous = record.and_then(|record| { + serde_json::from_value::(record.payload.clone()).ok() + }); + let payload = payload_from_header(document, previous.as_ref()); + let value = serde_json::to_value(&payload) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_NUMBERED_PAYLOAD_INVALID: {error}"))?; + let indexes = numbered_indexes(&payload); + if record + .map(|known| known.payload == value && known.indexes == indexes) + .unwrap_or(false) + { + continue; + } + mutations.push(crate::guanghu_numbered_store::NumberedMutation { + record_number: document.page_header.number.clone(), + record_type: "KNOWLEDGE_THOUGHT_INDEX".into(), + expected_revision: Some(record.map(|known| known.revision).unwrap_or(0)), + payload: value, + indexes, + tombstone: false, + }); + } + for record in existing + .iter() + .filter(|record| !present.contains(&record.record_number)) + { + mutations.push(crate::guanghu_numbered_store::NumberedMutation { + record_number: record.record_number.clone(), + record_type: record.record_type.clone(), + expected_revision: Some(record.revision), + payload: record.payload.clone(), + indexes: BTreeMap::new(), + tombstone: true, + }); + } + for chunk in mutations.chunks(256) { + crate::guanghu_numbered_store::commit_for_account( + app, + crate::guanghu_numbered_store::NumberedCommitInput { + collection_number: NUMBERED_KNOWLEDGE_COLLECTION.into(), + source: "KNOWLEDGE_NATIVE_INDEXER".into(), + authority_receipt: "ACCOUNT_SCOPED_DERIVED_INDEX".into(), + mutations: chunk.to_vec(), + }, + )?; + } + known = crate::guanghu_numbered_store::list_for_account(app, NUMBERED_KNOWLEDGE_COLLECTION)? + .into_iter() + .map(|record| (record.record_number.clone(), record)) + .collect(); + for document in documents.iter_mut() { + let record = known + .get(&document.page_header.number) + .ok_or_else(|| "HOLOLAKE_KNOWLEDGE_NUMBERED_READBACK_MISSING".to_string())?; + document.page_header = header_from_numbered_record(record)?; + } + Ok(()) +} + +fn ensure_numbered_store_seeded( + app: &AppHandle, + native: &Path, + legacy: Option<&Path>, +) -> Result<(), String> { + if crate::guanghu_numbered_store::list_for_account(app, NUMBERED_KNOWLEDGE_COLLECTION)? + .is_empty() + { + let mut snapshot = snapshot_at(native, legacy)?; + synchronize_number_index(native, &mut snapshot.documents)?; + synchronize_native_numbered_store(app, &mut snapshot.documents)?; + } + Ok(()) +} + fn synchronize_number_index( native: &Path, documents: &mut [KnowledgeDocumentSummary], @@ -910,25 +1225,36 @@ fn synchronize_number_index( .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_NUMBER_INDEX_INVALID: {error}"))?; transaction .execute( - "INSERT INTO numbered_pages(number,source,document_path,parent_number,storage_path,mapping_terms_json,children_json,source_version,content_sha256,state) - VALUES(?1,?2,?3,?4,?5,?6,'[]',?7,?8,'PENDING_THOUGHT_INDEX') + "INSERT INTO numbered_pages(number,source,document_path,title,parent_number,storage_path,mapping_terms_json,children_json,source_version,content_sha256,state) + VALUES(?1,?2,?3,?4,?5,?6,?7,'[]',?8,?9,'PENDING_THOUGHT_INDEX') ON CONFLICT(number) DO UPDATE SET source=excluded.source, document_path=excluded.document_path, + title=excluded.title, parent_number=excluded.parent_number, storage_path=excluded.storage_path, mapping_terms_json=excluded.mapping_terms_json, source_version=excluded.source_version, content_sha256=excluded.content_sha256, - state=CASE WHEN numbered_pages.trigger IS NOT NULL + state=CASE WHEN numbered_pages.content_sha256 != excluded.content_sha256 + THEN 'PENDING_THOUGHT_INDEX' + WHEN numbered_pages.trigger IS NOT NULL AND numbered_pages.emergence IS NOT NULL AND numbered_pages.lock_text IS NOT NULL AND numbered_pages.why IS NOT NULL - THEN 'READY' ELSE 'PENDING_THOUGHT_INDEX' END", + THEN 'READY' ELSE 'PENDING_THOUGHT_INDEX' END, + trigger=CASE WHEN numbered_pages.content_sha256=excluded.content_sha256 THEN numbered_pages.trigger ELSE NULL END, + emergence=CASE WHEN numbered_pages.content_sha256=excluded.content_sha256 THEN numbered_pages.emergence ELSE NULL END, + lock_text=CASE WHEN numbered_pages.content_sha256=excluded.content_sha256 THEN numbered_pages.lock_text ELSE NULL END, + why=CASE WHEN numbered_pages.content_sha256=excluded.content_sha256 THEN numbered_pages.why ELSE NULL END, + rejected_json=CASE WHEN numbered_pages.content_sha256=excluded.content_sha256 THEN numbered_pages.rejected_json ELSE '[]' END, + sources_json=CASE WHEN numbered_pages.content_sha256=excluded.content_sha256 THEN numbered_pages.sources_json ELSE '[]' END, + compiler=CASE WHEN numbered_pages.content_sha256=excluded.content_sha256 THEN numbered_pages.compiler ELSE NULL END", params![ header.number, document.source, document.path, + document.title, header.parent_number, header.path, mapping, @@ -965,25 +1291,36 @@ fn page_header_for_document( .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_NUMBER_INDEX_INVALID: {error}"))?; connection .execute( - "INSERT INTO numbered_pages(number,source,document_path,parent_number,storage_path,mapping_terms_json,children_json,source_version,content_sha256,state) - VALUES(?1,?2,?3,?4,?5,?6,'[]',?7,?8,'PENDING_THOUGHT_INDEX') + "INSERT INTO numbered_pages(number,source,document_path,title,parent_number,storage_path,mapping_terms_json,children_json,source_version,content_sha256,state) + VALUES(?1,?2,?3,?4,?5,?6,?7,'[]',?8,?9,'PENDING_THOUGHT_INDEX') ON CONFLICT(number) DO UPDATE SET source=excluded.source, document_path=excluded.document_path, + title=excluded.title, parent_number=excluded.parent_number, storage_path=excluded.storage_path, mapping_terms_json=excluded.mapping_terms_json, source_version=excluded.source_version, content_sha256=excluded.content_sha256, - state=CASE WHEN numbered_pages.trigger IS NOT NULL + state=CASE WHEN numbered_pages.content_sha256 != excluded.content_sha256 + THEN 'PENDING_THOUGHT_INDEX' + WHEN numbered_pages.trigger IS NOT NULL AND numbered_pages.emergence IS NOT NULL AND numbered_pages.lock_text IS NOT NULL AND numbered_pages.why IS NOT NULL - THEN 'READY' ELSE 'PENDING_THOUGHT_INDEX' END", + THEN 'READY' ELSE 'PENDING_THOUGHT_INDEX' END, + trigger=CASE WHEN numbered_pages.content_sha256=excluded.content_sha256 THEN numbered_pages.trigger ELSE NULL END, + emergence=CASE WHEN numbered_pages.content_sha256=excluded.content_sha256 THEN numbered_pages.emergence ELSE NULL END, + lock_text=CASE WHEN numbered_pages.content_sha256=excluded.content_sha256 THEN numbered_pages.lock_text ELSE NULL END, + why=CASE WHEN numbered_pages.content_sha256=excluded.content_sha256 THEN numbered_pages.why ELSE NULL END, + rejected_json=CASE WHEN numbered_pages.content_sha256=excluded.content_sha256 THEN numbered_pages.rejected_json ELSE '[]' END, + sources_json=CASE WHEN numbered_pages.content_sha256=excluded.content_sha256 THEN numbered_pages.sources_json ELSE '[]' END, + compiler=CASE WHEN numbered_pages.content_sha256=excluded.content_sha256 THEN numbered_pages.compiler ELSE NULL END", params![ &pending.number, source, path, + title, &pending.parent_number, &pending.path, mapping, @@ -1001,7 +1338,7 @@ fn page_header_from_index( ) -> Result { connection .query_row( - "SELECT source,parent_number,storage_path,mapping_terms_json,trigger,emergence,lock_text,why,children_json,source_version,content_sha256,state + "SELECT source,parent_number,storage_path,mapping_terms_json,trigger,emergence,lock_text,why,rejected_json,sources_json,children_json,source_version,content_sha256,state FROM numbered_pages WHERE number=?1", [number], |row| { @@ -1011,11 +1348,20 @@ fn page_header_from_index( let why: Option = row.get(7)?; let thought_summary = match (trigger, emergence, lock, why) { (Some(trigger), Some(emergence), Some(lock), Some(why)) => { - Some(KnowledgeThoughtSummary { trigger, emergence, lock, why }) + Some(KnowledgeThoughtSummary { + trigger, + emergence, + lock, + why, + rejected: serde_json::from_str(&row.get::<_, String>(8)?) + .unwrap_or_default(), + sources: serde_json::from_str(&row.get::<_, String>(9)?) + .unwrap_or_default(), + }) } _ => None, }; - let state: String = row.get(11)?; + let state: String = row.get(13)?; Ok(KnowledgePageHeader { schema: PAGE_HEADER_SCHEMA, number: number.into(), @@ -1025,11 +1371,11 @@ fn page_header_from_index( mapping_terms: serde_json::from_str::>(&row.get::<_, String>(3)?) .unwrap_or_default(), thought_summary, - children: serde_json::from_str::>(&row.get::<_, String>(8)?) + children: serde_json::from_str::>(&row.get::<_, String>(10)?) .unwrap_or_default(), source: row.get(0)?, - version: row.get::<_, u64>(9)? as u128, - content_sha256: row.get(10)?, + version: row.get::<_, u64>(11)? as u128, + content_sha256: row.get(12)?, formal_routing: state == "READY", state, }) @@ -1038,6 +1384,116 @@ fn page_header_from_index( .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_NUMBER_NOT_FOUND: {error}")) } +fn compile_thought_index_at( + native: &Path, + input: CompileKnowledgeThoughtIndexInput, +) -> Result { + if !input.document_number.starts_with("HLP-KB-DOC-") + || input.expected_content_sha256.len() != 64 + || !input + .expected_content_sha256 + .chars() + .all(|value| value.is_ascii_hexdigit() && !value.is_ascii_uppercase()) + { + return Err("HOLOLAKE_KNOWLEDGE_THOUGHT_INDEX_TARGET_INVALID".into()); + } + let compiler = input.compiler.trim(); + if !matches!(compiler, "HUMAN_VERIFIED" | "CHANNEL_PERSONA_COMPILED") { + return Err("HOLOLAKE_KNOWLEDGE_THOUGHT_INDEX_COMPILER_INVALID".into()); + } + validate_thought_summary(&input.thought_summary)?; + let mut mappings = input + .mapping_terms + .into_iter() + .map(|value| value.trim().to_lowercase()) + .filter(|value| !value.is_empty()) + .collect::>(); + mappings.sort(); + mappings.dedup(); + if mappings.is_empty() + || mappings.len() > 96 + || mappings.iter().any(|value| value.chars().count() > 80) + { + return Err("HOLOLAKE_KNOWLEDGE_THOUGHT_INDEX_MAPPING_INVALID".into()); + } + let connection = open_numbered_index(native)?; + let observed = connection + .query_row( + "SELECT content_sha256,state FROM numbered_pages WHERE number=?1", + [&input.document_number], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_NUMBER_INDEX_READ_FAILED: {error}"))? + .ok_or_else(|| "HOLOLAKE_KNOWLEDGE_NUMBER_NOT_FOUND".to_string())?; + if observed.1 == "SOURCE_MISSING" { + return Err("HOLOLAKE_KNOWLEDGE_NUMBER_SOURCE_MISSING".into()); + } + if observed.0 != input.expected_content_sha256 { + return Err("HOLOLAKE_KNOWLEDGE_THOUGHT_INDEX_CONTENT_CHANGED".into()); + } + let mapping_json = serde_json::to_string(&mappings) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_THOUGHT_INDEX_INVALID: {error}"))?; + let rejected_json = serde_json::to_string(&input.thought_summary.rejected) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_THOUGHT_INDEX_INVALID: {error}"))?; + let sources_json = serde_json::to_string(&input.thought_summary.sources) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_THOUGHT_INDEX_INVALID: {error}"))?; + let changed = connection + .execute( + "UPDATE numbered_pages SET mapping_terms_json=?1,trigger=?2,emergence=?3,lock_text=?4,why=?5,rejected_json=?6,sources_json=?7,compiler=?8,state='READY' WHERE number=?9 AND content_sha256=?10 AND state!='SOURCE_MISSING'", + params![ + mapping_json, + input.thought_summary.trigger, + input.thought_summary.emergence, + input.thought_summary.lock, + input.thought_summary.why, + rejected_json, + sources_json, + compiler, + input.document_number, + input.expected_content_sha256, + ], + ) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_THOUGHT_INDEX_WRITE_FAILED: {error}"))?; + if changed != 1 { + return Err("HOLOLAKE_KNOWLEDGE_THOUGHT_INDEX_WRITE_CONFLICT".into()); + } + Ok(KnowledgeThoughtIndexReceipt { + schema: "hololake.knowledge-thought-index-receipt/v1", + state: "READY", + document_number: input.document_number, + content_sha256: input.expected_content_sha256, + compiler: compiler.into(), + formal_routing: true, + }) +} + +fn validate_thought_summary(summary: &KnowledgeThoughtSummary) -> Result<(), String> { + for value in [ + &summary.trigger, + &summary.emergence, + &summary.lock, + &summary.why, + ] { + let length = value.trim().chars().count(); + if !(2..=2_000).contains(&length) { + return Err("HOLOLAKE_KNOWLEDGE_THOUGHT_INDEX_CAUSAL_FIELD_INVALID".into()); + } + } + if summary.rejected.len() > 24 + || summary.sources.is_empty() + || summary.sources.len() > 32 + || summary + .rejected + .iter() + .chain(summary.sources.iter()) + .any(|value| value.trim().is_empty() || value.chars().count() > 1_000) + { + return Err("HOLOLAKE_KNOWLEDGE_THOUGHT_INDEX_EVIDENCE_INVALID".into()); + } + Ok(()) +} + fn read_document_at( native: &Path, legacy: Option<&Path>, @@ -1056,15 +1512,14 @@ fn read_document_at( let writable = input.source == "native"; let title = title_from_text(&body, &path); let updated_at_unix_ms = modified_unix_ms(&metadata); - let page_header = page_header_for_document( - native, + let page_header = pending_page_header( &input.source, &input.path, &title, &body, updated_at_unix_ms, &content_sha256, - )?; + ); Ok(KnowledgeDocument { source: input.source, path: input.path, @@ -1132,6 +1587,119 @@ fn read_numbered_document_at( read_document_at(native, legacy, ReadKnowledgeDocumentInput { source, path }) } +fn read_numbered_document_from_store( + app: &AppHandle, + native: &Path, + legacy: Option<&Path>, + document_number: &str, +) -> Result { + if !document_number.starts_with("HLP-KB-DOC-") { + return Err("HOLOLAKE_KNOWLEDGE_NUMBER_INVALID".into()); + } + ensure_numbered_store_seeded(app, native, legacy)?; + let record = crate::guanghu_numbered_store::read_for_account( + app, + NUMBERED_KNOWLEDGE_COLLECTION, + document_number, + )?; + let payload: NumberedKnowledgePayload = serde_json::from_value(record.payload.clone()) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_NUMBERED_PAYLOAD_INVALID: {error}"))?; + let mut document = read_document_at( + native, + legacy, + ReadKnowledgeDocumentInput { + source: payload.source, + path: payload.document_path, + }, + )?; + if document.content_sha256 != payload.content_sha256 { + return Err("HOLOLAKE_KNOWLEDGE_NUMBERED_SOURCE_CHANGED".into()); + } + document.page_header = header_from_numbered_record(&record)?; + Ok(document) +} + +fn compile_thought_index_in_store( + app: &AppHandle, + native: &Path, + legacy: Option<&Path>, + input: CompileKnowledgeThoughtIndexInput, +) -> Result { + if !input.document_number.starts_with("HLP-KB-DOC-") + || input.expected_content_sha256.len() != 64 + { + return Err("HOLOLAKE_KNOWLEDGE_THOUGHT_INDEX_TARGET_INVALID".into()); + } + let compiler = input.compiler.trim(); + if !matches!(compiler, "HUMAN_VERIFIED" | "CHANNEL_PERSONA_COMPILED") { + return Err("HOLOLAKE_KNOWLEDGE_THOUGHT_INDEX_COMPILER_INVALID".into()); + } + validate_thought_summary(&input.thought_summary)?; + let mut mappings = input + .mapping_terms + .into_iter() + .map(|value| value.trim().to_lowercase()) + .filter(|value| !value.is_empty()) + .collect::>(); + mappings.sort(); + mappings.dedup(); + if mappings.is_empty() + || mappings.len() > 512 + || mappings.iter().any(|value| value.chars().count() > 200) + { + return Err("HOLOLAKE_KNOWLEDGE_THOUGHT_INDEX_MAPPING_INVALID".into()); + } + ensure_numbered_store_seeded(app, native, legacy)?; + let record = crate::guanghu_numbered_store::read_for_account( + app, + NUMBERED_KNOWLEDGE_COLLECTION, + &input.document_number, + )?; + let mut payload: NumberedKnowledgePayload = serde_json::from_value(record.payload.clone()) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_NUMBERED_PAYLOAD_INVALID: {error}"))?; + if payload.content_sha256 != input.expected_content_sha256 { + return Err("HOLOLAKE_KNOWLEDGE_THOUGHT_INDEX_CONTENT_CHANGED".into()); + } + payload.mapping_terms = mappings; + payload.thought_summary = Some(input.thought_summary); + payload.state = "READY".into(); + payload.compiler = Some(compiler.into()); + crate::guanghu_numbered_store::commit_for_account( + app, + crate::guanghu_numbered_store::NumberedCommitInput { + collection_number: NUMBERED_KNOWLEDGE_COLLECTION.into(), + source: "KNOWLEDGE_THOUGHT_COMPILER".into(), + authority_receipt: compiler.into(), + mutations: vec![crate::guanghu_numbered_store::NumberedMutation { + record_number: input.document_number.clone(), + record_type: "KNOWLEDGE_THOUGHT_INDEX".into(), + expected_revision: Some(record.revision), + payload: serde_json::to_value(&payload).map_err(|error| error.to_string())?, + indexes: numbered_indexes(&payload), + tombstone: false, + }], + }, + )?; + let readback = crate::guanghu_numbered_store::read_for_account( + app, + NUMBERED_KNOWLEDGE_COLLECTION, + &input.document_number, + )?; + let verified: NumberedKnowledgePayload = serde_json::from_value(readback.payload) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_NUMBERED_PAYLOAD_INVALID: {error}"))?; + if verified.state != "READY" || verified.content_sha256 != input.expected_content_sha256 { + return Err("HOLOLAKE_KNOWLEDGE_THOUGHT_INDEX_READBACK_FAILED".into()); + } + Ok(KnowledgeThoughtIndexReceipt { + schema: "hololake.knowledge-thought-index-receipt/v1", + state: "READY", + document_number: input.document_number, + content_sha256: input.expected_content_sha256, + compiler: compiler.into(), + formal_routing: true, + }) +} + fn save_document_at( native: &Path, input: SaveKnowledgeDocumentInput, @@ -1199,6 +1767,74 @@ fn save_document_at( }) } +fn search_numbered_store( + app: &AppHandle, + native: &Path, + legacy: Option<&Path>, + input: SearchKnowledgeInput, +) -> Result, String> { + let query = input.query.trim().to_lowercase(); + if query.is_empty() || query.chars().count() > 200 { + return Err("HOLOLAKE_KNOWLEDGE_SEARCH_QUERY_INVALID".into()); + } + ensure_numbered_store_seeded(app, native, legacy)?; + let mut terms = mapping_terms("", "", &query); + terms.truncate(128); + let records = crate::guanghu_numbered_store::query_any_for_account( + app, + NUMBERED_KNOWLEDGE_COLLECTION, + "mappingTerm", + &terms, + )?; + let mut ranked = Vec::new(); + for record in records { + let payload: NumberedKnowledgePayload = serde_json::from_value(record.payload) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_NUMBERED_PAYLOAD_INVALID: {error}"))?; + let known = payload.mapping_terms.iter().collect::>(); + let mut matched = terms + .iter() + .filter(|term| known.contains(term)) + .cloned() + .collect::>(); + matched.sort(); + matched.dedup(); + let title_lower = payload.title.to_lowercase(); + let path_lower = payload.document_path.to_lowercase(); + let score = matched.len() * 100 + + usize::from(title_lower.contains(&query)) * 400 + + usize::from(path_lower.contains(&query)) * 300 + + usize::from(payload.state == "READY") * 250; + let snippet = payload + .thought_summary + .as_ref() + .map(|summary| summary.emergence.clone()) + .unwrap_or_else(|| format!("待生成思维摘要 · 已命中映射:{}", matched.join("、"))); + ranked.push(( + score, + KnowledgeSearchResult { + source: payload.source, + number: record.record_number, + path: payload.document_path, + title: payload.title, + state: payload.state, + thought_summary: payload.thought_summary, + match_reason: matched, + snippet, + }, + )); + } + ranked.sort_by(|(left_score, left), (right_score, right)| { + right_score + .cmp(left_score) + .then_with(|| left.path.cmp(&right.path)) + }); + Ok(ranked + .into_iter() + .take(MAX_ROUTED_CANDIDATES) + .map(|(_, result)| result) + .collect()) +} + fn search_at( native: &Path, legacy: Option<&Path>, @@ -1208,17 +1844,25 @@ fn search_at( if query.is_empty() || query.chars().count() > 200 { return Err("HOLOLAKE_KNOWLEDGE_SEARCH_QUERY_INVALID".into()); } - let snapshot = snapshot_at(native, legacy)?; - let titles = snapshot - .documents - .iter() - .map(|document| (document.page_header.number.clone(), document.title.clone())) - .collect::>(); + // Search is intentionally index-only. A first-run empty index is seeded once; + // subsequent searches never walk or read the document tree. Imports, saves and + // explicit knowledge snapshots are the mutation points that refresh the index. + let seed = open_numbered_index(native)? + .query_row( + "SELECT COUNT(*) FROM numbered_pages WHERE state!='SOURCE_MISSING'", + [], + |row| row.get::<_, i64>(0), + ) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_NUMBER_INDEX_READ_FAILED: {error}"))?; + if seed == 0 { + let mut migration = snapshot_at(native, legacy)?; + synchronize_number_index(native, &mut migration.documents)?; + } let terms = mapping_terms("", "", &query); let connection = open_numbered_index(native)?; let mut statement = connection .prepare( - "SELECT number,source,document_path,mapping_terms_json,trigger,emergence,lock_text,why,state + "SELECT number,source,document_path,title,mapping_terms_json,trigger,emergence,lock_text,why,rejected_json,sources_json,state FROM numbered_pages WHERE state!='SOURCE_MISSING'", ) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_NUMBER_INDEX_READ_FAILED: {error}"))?; @@ -1229,18 +1873,35 @@ fn search_at( row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, - row.get::<_, Option>(4)?, + row.get::<_, String>(4)?, row.get::<_, Option>(5)?, row.get::<_, Option>(6)?, row.get::<_, Option>(7)?, - row.get::<_, String>(8)?, + row.get::<_, Option>(8)?, + row.get::<_, String>(9)?, + row.get::<_, String>(10)?, + row.get::<_, String>(11)?, )) }) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_NUMBER_INDEX_READ_FAILED: {error}"))? .collect::, _>>() .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_NUMBER_INDEX_READ_FAILED: {error}"))?; let mut ranked = Vec::new(); - for (number, source, path, mapping_json, trigger, emergence, lock, why, state) in rows { + for ( + number, + source, + path, + title, + mapping_json, + trigger, + emergence, + lock, + why, + rejected_json, + sources_json, + state, + ) in rows + { let mappings = serde_json::from_str::>(&mapping_json).unwrap_or_default(); let mut matched = terms .iter() @@ -1263,11 +1924,17 @@ fn search_at( emergence, lock, why, + rejected: serde_json::from_str(&rejected_json).unwrap_or_default(), + sources: serde_json::from_str(&sources_json).unwrap_or_default(), }) } _ => None, }; - let title = titles.get(&number).cloned().unwrap_or_else(|| path.clone()); + let title = if title.trim().is_empty() { + path.clone() + } else { + title + }; let title_lower = title.to_lowercase(); let path_lower = path.to_lowercase(); let score = matched.len() * 100 diff --git a/product-source/hololake-native-desktop/src-tauri/src/lib.rs b/product-source/hololake-native-desktop/src-tauri/src/lib.rs index 70c96ac35..a7cea4978 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/lib.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ mod code_repo_login; mod direct_local_broker; mod direct_local_session; mod dynamic_capability_routing; +mod education_broadcast_tower; mod education_translation; mod education_workspace; mod enterprise_work_channel; @@ -16,6 +17,7 @@ mod glp_envelope; mod gls_bootstrap_compiler; mod gls_protocol_kernel; mod gls_protocol_runtime; +mod guanghu_numbered_store; mod hldp_tool_forge; mod home_status; mod human_authorization; diff --git a/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc.rs b/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc.rs index 5c439a9b7..e3d4ddde3 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc.rs @@ -934,7 +934,7 @@ mod tests { #[test] fn registry_is_closed_and_contains_every_migrated_command() { let registry = load_registry().unwrap(); - assert_eq!(registry.operations.len(), 177); + assert_eq!(registry.operations.len(), 184); assert!(!registry.runtime.legacy_direct_commands_allowed); } diff --git a/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc_dispatch.rs b/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc_dispatch.rs index 219d4dc31..477c50734 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc_dispatch.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc_dispatch.rs @@ -300,6 +300,25 @@ pub(crate) async fn dispatch( "education_workspace::get_education_workspace_snapshot" => { json(crate::education_workspace::get_education_workspace_snapshot(app).await?) } + "education_broadcast_tower::get_snapshot" => { + json(crate::education_broadcast_tower::get_snapshot(app).await?) + } + "education_broadcast_tower::register_participant" => json( + crate::education_broadcast_tower::register_participant(app, input(&payload)?).await?, + ), + "education_broadcast_tower::create_learning_plan" => json( + crate::education_broadcast_tower::create_learning_plan(app, input(&payload)?).await?, + ), + "education_broadcast_tower::transition_learning_plan" => json( + crate::education_broadcast_tower::transition_learning_plan(app, input(&payload)?) + .await?, + ), + "education_broadcast_tower::issue_push_ticket" => { + json(crate::education_broadcast_tower::issue_push_ticket(app, input(&payload)?).await?) + } + "education_broadcast_tower::claim_push_ticket" => { + json(crate::education_broadcast_tower::claim_push_ticket(app, input(&payload)?).await?) + } "education_workspace::create_education_document" => json( crate::education_workspace::create_education_document(app, input(&payload)?).await?, ), @@ -534,6 +553,9 @@ pub(crate) async fn dispatch( "knowledge_base::search_knowledge" => { json(crate::knowledge_base::search_knowledge(app, input(&payload)?).await?) } + "knowledge_base::compile_knowledge_thought_index" => json( + crate::knowledge_base::compile_knowledge_thought_index(app, input(&payload)?).await?, + ), "knowledge_base::save_knowledge_document" => { json(crate::knowledge_base::save_knowledge_document(app, input(&payload)?).await?) } diff --git a/product-source/hololake-native-desktop/src/modules/numbered-ipc.ts b/product-source/hololake-native-desktop/src/modules/numbered-ipc.ts index 11c6967bd..f06e32a87 100644 --- a/product-source/hololake-native-desktop/src/modules/numbered-ipc.ts +++ b/product-source/hololake-native-desktop/src/modules/numbered-ipc.ts @@ -1416,6 +1416,62 @@ const ROUTES = { "moduleNumber": "HLP-NIPC-MOD-0030", "operationNumber": "HLP-NIPC-OP-0177", "targetNumber": "HLP-NIPC-TGT-0030" + }, + "compile_knowledge_thought_index": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0018", + "operationNumber": "HLP-NIPC-OP-0178", + "targetNumber": "HLP-NIPC-TGT-0018" + }, + "get_education_broadcast_tower": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0179", + "targetNumber": "HLP-NIPC-TGT-0024" + }, + "register_education_participant": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0180", + "targetNumber": "HLP-NIPC-TGT-0024" + }, + "create_education_learning_plan": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0181", + "targetNumber": "HLP-NIPC-TGT-0024" + }, + "transition_education_learning_plan": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0182", + "targetNumber": "HLP-NIPC-TGT-0024" + }, + "issue_education_push_ticket": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0183", + "targetNumber": "HLP-NIPC-TGT-0024" + }, + "claim_education_push_ticket": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0184", + "targetNumber": "HLP-NIPC-TGT-0024" } } as const